Skip to main content

speccoder_runtime/
actions.rs

1//! What a person can do now, and taking it. The list comes from the
2//! project as it stands: the report, what waits for a decision, the
3//! planning files not yet committed and the installed planning skills.
4//! Interfaces render the list; [`perform`] is the one place an action's
5//! [`Runnable`] becomes runtime calls, for every interface.
6//!
7//! # Adding a kind
8//!
9//! Every new person-initiated action — a model run or a plain write —
10//! lands in one change with all of:
11//!
12//! 1. a `Runnable` variant with typed fields (`speccoder_core::actions`);
13//! 2. its `ActionKind`, its `identity` arm and its subject rule (same file);
14//! 3. exactly one arm in [`perform_in`] (input rules, `needs(..)`
15//!    messages, the dispatch's `via` passed through);
16//! 4. its result payload: an existing `CommandData` variant, or a new one
17//!    with a unique name in `PAYLOADS`;
18//! 5. that payload's published schema (`SPECCODER_BLESS=1`);
19//! 6. its generated UI type (`crates/speccoder-web/ui/src/lib/schema.gen.ts`);
20//! 7. a decision for every interface in [`offered`] (no wildcard: the
21//!    build fails until each interface has one);
22//! 8. a golden for its arm (`tests/fixtures/perform/`);
23//! 9. if the web app shows it, its verb and icon in the UI's kind table
24//!    (`ui/src/lib/actions.ts`).
25//!
26//! No feature adds a web job kind, a web route that writes, a terminal
27//! prompt, an editor command or a dashboard key that maps a kind to runtime
28//! calls itself, nor a marker on an existing variant meaning another
29//! action: it adds a kind. Only the interfaces of [`Interface`] call
30//! [`perform`] — never a model tool, a hook, a gate or the engine — so
31//! nothing but a person's command approves, rejects, accepts, rules or
32//! ticks through it.
33
34use std::path::Path;
35
36use speccoder_core::actions::{Action, ActionKind, Runnable, Via};
37use speccoder_core::{ApprovalsReport, CommandData, CommandResult, CommandStatus, StatusReport};
38
39use crate::RunOptions;
40
41/// How an interface takes a line speccoder printed — a fix or a next
42/// step: performed through [`perform`], a view, the person's own command,
43/// or a line for their shell. The one parser every builder's lines go
44/// through (in their serialisation).
45pub use speccoder_core::step::{Context, How, Step, View, step_of as runnable_of};
46
47pub(crate) fn action(
48    group: &str,
49    label: impl Into<String>,
50    why: impl Into<String>,
51    command: impl Into<String>,
52) -> Action {
53    Action::new(group, label, why, command)
54}
55
56/// The reason an I-did-it tick records when the person gave none.
57pub const DONE_REASON: &str = "I did it";
58
59/// What can be done now in the project at `path` (`feature` as for every
60/// command), with `selected` the task an interface has selected.
61#[must_use]
62pub fn available(path: &Path, feature: Option<&str>, selected: Option<&str>) -> Vec<Action> {
63    available_with(path, feature, selected, None)
64}
65
66/// [`available`] with the waiting calls already listed (an interface that
67/// lists them anyway): the journal is not opened again.
68#[must_use]
69pub fn available_with(
70    path: &Path,
71    feature: Option<&str>,
72    selected: Option<&str>,
73    listed: Option<&ApprovalsReport>,
74) -> Vec<Action> {
75    let report = crate::status::report(path, feature);
76    let report: Option<&StatusReport> = match report.data() {
77        Some(CommandData::Report(r)) => Some(r),
78        _ => None,
79    };
80    let approvals = listed.cloned().or_else(|| {
81        crate::worktree::state_dir().and_then(|state| {
82            match crate::approvals::list_with(path, feature, &state).data() {
83                Some(CommandData::Approvals(a)) => Some((**a).clone()),
84                _ => None,
85            }
86        })
87    });
88    let uncommitted = crate::hosting::uncommitted(path, feature);
89    let skills = match crate::hosting::list(path).data() {
90        Some(CommandData::Skills(s)) => s.skills.clone(),
91        _ => Vec::new(),
92    };
93    let format = crate::format::at(path);
94    let planning = format.planning();
95    let unit = format.messages().unit;
96    let features = crate::current::list(path, None);
97    let feature_count = match features.data() {
98        Some(CommandData::Features(f)) => f.features.len(),
99        _ => 0,
100    };
101    let short = |name: &str| {
102        name.strip_prefix(planning.prefix)
103            .unwrap_or(name)
104            .to_owned()
105    };
106    let skill = |group: &str, name: &str, why: &str| {
107        let creates = planning.creates.contains(&name);
108        let mut a = action(
109            group,
110            format!("spec {name}"),
111            why,
112            if creates {
113                format!("speccoder spec {name} \"…\"")
114            } else {
115                format!("speccoder spec {name}")
116            },
117        );
118        a = a
119            .with_run(Runnable::Skill {
120                name: name.to_owned(),
121                arguments: String::new(),
122            })
123            .with_role(planning.role_of(name));
124        if creates {
125            a.input = Some(format!("Describe the {unit}"));
126        }
127        a
128    };
129    let mut out = Vec::new();
130
131    // ── next: what moves the work forward, most urgent first ──────────
132    for p in approvals.iter().flat_map(|a: &ApprovalsReport| &a.pending) {
133        let mut a = action(
134            "next",
135            format!("approve {}'s waiting call", p.task),
136            format!("{} — {}", p.covers, p.reason),
137            format!("speccoder task approve {}", p.task),
138        );
139        a = a.with_run(Runnable::Approve {
140            task: p.task.clone(),
141            call: p.id(),
142            digest: p.digest.clone(),
143        });
144        out.push(a);
145        let mut r = action(
146            "next",
147            format!("reject {}'s waiting call", p.task),
148            "the model is told your reason",
149            format!("speccoder task reject {} --reason \"…\"", p.task),
150        );
151        r = r.with_run(Runnable::Reject {
152            task: p.task.clone(),
153            call: p.id(),
154            digest: p.digest.clone(),
155        });
156        r.input = Some("Why (the model is told)".to_owned());
157        out.push(r);
158    }
159    let planning_next: Vec<String> = report.map(|r| r.planning.clone()).unwrap_or_default();
160    // Plan through: the rest of the chain in one action, from the files —
161    // offered after a stop as its continuation (no chain state is kept).
162    if let Some(first) = planning_next.first() {
163        let mut a = action(
164            "next",
165            "plan through to tasks",
166            "the format's own skills in order; it stops for open questions and your review",
167            format!("speccoder spec {first} --through tasks"),
168        );
169        a = a.with_run(Runnable::PlanThrough {
170            from: speccoder_core::actions::PlanFrom::Continue,
171            through: speccoder_core::actions::Through::Tasks,
172        });
173        let gate = crate::review::summary(path, feature).and_then(|r| {
174            if !r.questions.is_empty() {
175                Some(format!(
176                    "answer the {} open question{} first",
177                    r.questions.len(),
178                    if r.questions.len() == 1 { "" } else { "s" }
179                ))
180            } else {
181                (!r.requirements.is_empty() && !r.totals.complete())
182                    .then(|| "approve the spec's requirements first (Review)".to_owned())
183            }
184        });
185        a.blocked = gate;
186        out.push(a);
187    }
188    for name in &planning_next {
189        let why = match name.as_str() {
190            "plan" => "write the plan from the spec",
191            "tasks" => "break the plan into tasks",
192            _ => "the next planning step",
193        };
194        out.push(skill("next", name, why));
195    }
196    if !uncommitted.is_empty() {
197        let mut a = action(
198            "next",
199            format!("commit the planning files ({})", uncommitted.len()),
200            "speccoder runs from committed files",
201            format!(
202                "git add {0} && git commit -m \"spec: …\" -- {0}",
203                uncommitted.join(" ")
204            ),
205        );
206        a = a.with_run(Runnable::Commit {
207            paths: uncommitted.clone(),
208        });
209        out.push(a);
210    }
211    let ready = report.map_or(0, |r| r.totals.ready);
212    let run_blocked = (!uncommitted.is_empty())
213        .then(|| "commit the planning files first: speccoder runs from committed files".to_owned());
214    if ready > 0 && planning_next.is_empty() {
215        let mut a = action(
216            "next",
217            "run the feature",
218            format!("{ready} ready task(s), each judged on evidence"),
219            "speccoder run",
220        );
221        a = a.with_run(Runnable::Run {
222            task: None,
223            agent: None,
224        });
225        a.blocked = run_blocked.clone();
226        out.push(a);
227    }
228    // The spec changed under ticks that cite it: their evidence is for the
229    // earlier wording. Convergence assesses the code against it as it is.
230    let stale: Vec<&str> = report
231        .map(|r| {
232            r.tasks
233                .iter()
234                .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
235                .map(|t| t.id.as_str())
236                .collect()
237        })
238        .unwrap_or_default();
239    if !stale.is_empty() && planning_next.is_empty() {
240        let mut a = action(
241            "next",
242            "re-check what the spec changed",
243            format!(
244                "{} 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",
245                stale.join(", ")
246            ),
247            "speccoder run --converge",
248        );
249        a = a.with_run(Runnable::Converge);
250        a.blocked = run_blocked.clone();
251        out.push(a);
252    }
253    if report.is_none()
254        && feature_count == 0
255        && format.detect(path).is_some()
256        && let Some(create) = planning.creates.first()
257    {
258        out.push(skill(
259            "next",
260            create,
261            &format!("write the first {unit}'s spec"),
262        ));
263    }
264
265    // ── plan: every hosted planning skill ─────────────────────────────
266    for s in skills.iter().filter(|s| s.refused.is_none()) {
267        let name = short(&s.name);
268        if planning_next.contains(&name) {
269            continue;
270        }
271        out.push(skill("plan", &name, &s.description));
272    }
273
274    // ── work ───────────────────────────────────────────────────────────
275    if let Some(t) =
276        selected.and_then(|id| report.and_then(|r| r.tasks.iter().find(|t| t.id == id)))
277    {
278        if t.state == "ready" {
279            let mut a = action(
280                "work",
281                format!("run {}", t.id),
282                t.title.clone(),
283                format!("speccoder run --task {}", t.id),
284            );
285            a = a.with_run(Runnable::Run {
286                task: Some(t.id.clone()),
287                agent: None,
288            });
289            a.blocked = run_blocked.clone();
290            out.push(a);
291        }
292        // A quick change or bug that grew: start a spec from it, or not now.
293        if let (Some(r), Some(state)) = (report, crate::worktree::state_dir()) {
294            let plane = crate::worktree::project_state_dir(&state, path);
295            if let Some(o) =
296                crate::ladder::offer_for(path, &plane, &format!("{}/{}", r.feature, t.id))
297            {
298                let at = format!("{}/{}", o.unit, o.task);
299                out.push(
300                    action(
301                        "review",
302                        format!("start a spec from {}", t.id),
303                        o.signals
304                            .iter()
305                            .map(|s| s.text.clone())
306                            .collect::<Vec<_>>()
307                            .join(" · "),
308                        format!("speccoder spec promote {at}"),
309                    )
310                    .with_run(crate::ladder::promotion(
311                        &o,
312                        crate::ladder::suggested_by(&plane, &o),
313                    )),
314                );
315                out.push(
316                    action(
317                        "review",
318                        format!(
319                            "not now: keep {} a {}",
320                            t.id,
321                            if o.bug { "bug fix" } else { "quick change" }
322                        ),
323                        "offered again only when another signal holds",
324                        format!("speccoder spec promote {at} --decline"),
325                    )
326                    .with_run(Runnable::DeclinePromotion { task: at }),
327                );
328            }
329        }
330        // ── task: the selected one ────────────────────────────────────
331        if !matches!(
332            t.state.as_str(),
333            "verified" | "regression_only" | "attested"
334        ) {
335            // Promoted while still open: it goes on as a spec of its own;
336            // skipping it here is the person's explicit step (FR-024).
337            let promoted = report.zip(crate::worktree::state_dir()).and_then(|(r, s)| {
338                crate::ladder::promoted_to(
339                    &crate::worktree::project_state_dir(&s, path),
340                    &format!("{}/{}", r.feature, t.id),
341                )
342            });
343            let skip_why = promoted.as_ref().map_or_else(
344                || "runs pass it by; what depends on it stays blocked".to_owned(),
345                |to| format!("it goes on as {to}; runs here pass it by"),
346            );
347            let mut verbs = [
348                (
349                    "accept",
350                    "tick it on your word (attested, not verified)".to_owned(),
351                    "Why it is done",
352                    Runnable::Accept { task: t.id.clone() },
353                ),
354                (
355                    "skip",
356                    skip_why,
357                    "Why it is skipped",
358                    Runnable::Skip { task: t.id.clone() },
359                ),
360                (
361                    "nudge",
362                    "a note its next attempts read".to_owned(),
363                    "The note",
364                    Runnable::Nudge { task: t.id.clone() },
365                ),
366            ];
367            if promoted.is_some() {
368                verbs.swap(0, 1);
369            }
370            for (verb, why, prompt, run) in verbs {
371                let flag = if verb == "nudge" {
372                    "--note"
373                } else {
374                    "--reason"
375                };
376                let mut a = action(
377                    "task",
378                    format!("{verb} {}", t.id),
379                    why,
380                    format!("speccoder task {verb} {} {flag} \"…\"", t.id),
381                );
382                a = a.with_run(run);
383                a.input = Some(prompt.to_owned());
384                out.push(a);
385            }
386        }
387    }
388    if ready > 0 && !planning_next.is_empty() {
389        // Tasks exist and planning goes on: running is possible too.
390        let mut a = action(
391            "work",
392            "run the feature",
393            format!("{ready} ready task(s)"),
394            "speccoder run",
395        );
396        a = a.with_run(Runnable::Run {
397            task: None,
398            agent: None,
399        });
400        a.blocked = run_blocked;
401        out.push(a);
402    }
403
404    // ── feature ────────────────────────────────────────────────────────
405    if feature_count > 1 {
406        let mut a = action(
407            "feature",
408            format!("switch the {unit}"),
409            format!("{feature_count} {unit}s"),
410            "speccoder feature <name>",
411        );
412        a = a.with_run(Runnable::Feature);
413        out.push(a);
414    }
415
416    // ── review: commands that leave the machine or are best read in full ─
417    // It leaves the machine: an interface confirms it first.
418    let mut pr = action(
419        "review",
420        "open a draft pull request",
421        "push the branch and its evidence; open (or update) the draft",
422        "speccoder pr --yes",
423    );
424    pr = pr.with_run(Runnable::PullRequest);
425    pr.confirm = Some(
426        "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(),
427    );
428    out.push(pr);
429    out.push(action(
430        "review",
431        "verify the attestations",
432        "every tick against its commit and signature",
433        "speccoder attest verify",
434    ));
435    out.push(action(
436        "review",
437        "check the setup",
438        "git, the project, the config, the model key",
439        "speccoder doctor",
440    ));
441    out
442}
443
444/// `speccoder actions`: what can be done now in the project at `path`
445/// (with `selected` the task an interface has selected) and where the
446/// feature is on its journey — the list every interface renders. Outside
447/// a project: what `report` says there, with an empty list.
448#[must_use]
449pub fn list(path: &Path, feature: Option<&str>, selected: Option<&str>) -> CommandResult {
450    let report = || speccoder_core::ActionsReport {
451        actions: Vec::new(),
452        journey: journey(path, feature),
453    };
454    if crate::format::at(path).detect(path).is_none() {
455        let status = crate::status::report(path, feature);
456        return CommandResult::carried("actions", &status)
457            .with_data(CommandData::Actions(Box::new(report())));
458    }
459    let mut r = report();
460    r.actions = available(path, feature, selected);
461    let message = match speccoder_core::actions::primary(&r.actions) {
462        Some(a) => format!("{} action(s); next: {}", r.actions.len(), a.label),
463        None => format!("{} action(s)", r.actions.len()),
464    };
465    CommandResult::ok("actions", &message).with_data(CommandData::Actions(Box::new(r)))
466}
467
468/// Where the feature is on its journey (spec → plan → tasks → commit → run
469/// → review); the report's, or the start of it.
470#[must_use]
471pub fn journey(path: &Path, feature: Option<&str>) -> Vec<speccoder_core::actions::JourneyStep> {
472    match crate::status::report(path, feature).data() {
473        Some(CommandData::Report(r)) => r.journey.clone(),
474        _ => speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts::default()),
475    }
476}
477
478/// An interface that takes actions, and so decides which kinds it offers.
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub enum Interface {
481    /// The web host's jobs (`POST /api/run`).
482    WebJobs,
483    /// The line CLI's offer of the next step.
484    CliFollow,
485    /// The line CLI's offer of a fix speccoder knows.
486    CliRemedy,
487    /// An editor's fix, over the Agent Client Protocol.
488    AcpRemedy,
489    /// The dashboard's own keys (its `n` hands off to [`Interface::CliFollow`]).
490    Dashboard,
491    /// An Ask proposal a person confirmed in the terminal or an editor.
492    AskProposal,
493}
494
495impl Interface {
496    /// Every interface.
497    pub const ALL: &'static [Self] = &[
498        Self::WebJobs,
499        Self::CliFollow,
500        Self::CliRemedy,
501        Self::AcpRemedy,
502        Self::Dashboard,
503        Self::AskProposal,
504    ];
505
506    /// What it answers for a kind it does not offer.
507    #[must_use]
508    pub fn refusal(self) -> CommandResult {
509        match self {
510            Self::CliFollow => CommandResult::new(
511                "next",
512                CommandStatus::UsageError,
513                "not a step speccoder takes here",
514            ),
515            Self::Dashboard => CommandResult::new(
516                "tui",
517                CommandStatus::UsageError,
518                "not something the dashboard does",
519            ),
520            Self::WebJobs => CommandResult::new(
521                "job",
522                CommandStatus::UsageError,
523                "not something the web app runs",
524            ),
525            Self::AskProposal => CommandResult::new(
526                "ask",
527                CommandStatus::UsageError,
528                "not something an Ask proposal records",
529            ),
530            Self::CliRemedy | Self::AcpRemedy => CommandResult::new(
531                "fix",
532                CommandStatus::UsageError,
533                "not a fix speccoder applies here",
534            ),
535        }
536    }
537}
538
539/// Whether an interface takes a kind.
540#[derive(Debug, Clone, Copy, PartialEq, Eq)]
541pub enum Offer {
542    /// It does.
543    Take,
544    /// It does not: it answers with [`Interface::refusal`], before anything runs.
545    Refuse,
546}
547
548/// Which kinds each interface takes: every pair decided, no default, so a
549/// new kind does not build until each interface has a decision.
550#[must_use]
551pub const fn offered(interface: Interface, kind: ActionKind) -> Offer {
552    use ActionKind as K;
553    match interface {
554        Interface::WebJobs => match kind {
555            K::Skill
556            | K::RunTask
557            | K::RunFeature
558            | K::Converge
559            | K::Commit
560            | K::Approve
561            | K::Reject
562            | K::Accept
563            | K::Skip
564            | K::Nudge
565            | K::Done
566            | K::Hint
567            | K::Rule
568            | K::Review
569            | K::IssueComment
570            | K::QuickChange
571            | K::KeepSetAside
572            | K::DropSetAside
573            | K::FixSetup
574            | K::Feature
575            | K::PullRequest
576            | K::BugFix
577            | K::BugConfirm
578            | K::BugRun
579            | K::BugKeep
580            | K::AllowAlways
581            | K::WidenPolicy
582            | K::DecideBatch
583            | K::OracleTests
584            | K::CheckStory
585            | K::ApproveAndRun
586            | K::Comment
587            | K::CommentsSend
588            | K::RevisionUndo
589            | K::CodeNudge
590            | K::AcceptanceAt
591            | K::RuleScenario
592            | K::AcceptUntestable
593            | K::PresetAdd
594            | K::PlanThrough
595            | K::NewProject
596            | K::UndoNewProject
597            | K::CloneRepository
598            | K::OpenSample
599            | K::SampleChecks
600            | K::DeclinePromotion
601            | K::Explore
602            | K::ExploreChoose => Offer::Take,
603            K::Command => Offer::Refuse,
604        },
605        // Decisions belong to their own prompt; what leaves the machine
606        // or asks for words is the person's own command there.
607        Interface::CliFollow => match kind {
608            // Plan through's continuation is the `Next:` step after a stop.
609            K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
610                Offer::Take
611            }
612            K::Approve
613            | K::Reject
614            | K::Accept
615            | K::Skip
616            | K::Nudge
617            | K::Done
618            | K::Hint
619            | K::Rule
620            | K::Review
621            | K::IssueComment
622            | K::QuickChange
623            | K::KeepSetAside
624            | K::DropSetAside
625            | K::FixSetup
626            | K::Feature
627            | K::PullRequest
628            | K::BugFix
629            | K::BugConfirm
630            | K::BugRun
631            | K::BugKeep
632            | K::AllowAlways
633            | K::WidenPolicy
634            | K::DecideBatch
635            | K::OracleTests
636            | K::CheckStory
637            | K::ApproveAndRun
638            | K::Comment
639            | K::CommentsSend
640            | K::RevisionUndo
641            | K::CodeNudge
642            | K::AcceptanceAt
643            | K::RuleScenario
644            | K::AcceptUntestable
645            | K::PresetAdd
646            | K::NewProject
647            | K::UndoNewProject
648            | K::CloneRepository
649            | K::OpenSample
650            | K::SampleChecks
651            | K::DeclinePromotion
652            | K::Explore
653            | K::ExploreChoose
654            | K::Command => Offer::Refuse,
655        },
656        // What a proposal may be: a quick change, a bug fix, a nudge, an
657        // answer to an open question, a new spec. Nothing else.
658        Interface::AskProposal => match kind {
659            K::QuickChange | K::BugFix | K::Nudge | K::Review | K::PlanThrough => Offer::Take,
660            K::Skill
661            | K::RunTask
662            | K::RunFeature
663            | K::Converge
664            | K::Commit
665            | K::Approve
666            | K::Reject
667            | K::Accept
668            | K::Skip
669            | K::Done
670            | K::Hint
671            | K::Rule
672            | K::IssueComment
673            | K::KeepSetAside
674            | K::DropSetAside
675            | K::FixSetup
676            | K::Feature
677            | K::PullRequest
678            | K::BugConfirm
679            | K::BugRun
680            | K::BugKeep
681            | K::AllowAlways
682            | K::WidenPolicy
683            | K::DecideBatch
684            | K::OracleTests
685            | K::CheckStory
686            | K::ApproveAndRun
687            | K::Comment
688            | K::CommentsSend
689            | K::RevisionUndo
690            | K::CodeNudge
691            | K::AcceptanceAt
692            | K::RuleScenario
693            | K::AcceptUntestable
694            | K::PresetAdd
695            | K::NewProject
696            | K::UndoNewProject
697            | K::CloneRepository
698            | K::OpenSample
699            | K::SampleChecks
700            | K::DeclinePromotion
701            | K::Explore
702            | K::ExploreChoose
703            | K::Command => Offer::Refuse,
704        },
705        // Its keys decide the queue's calls and switch the feature; `n`
706        // hands the next step to the line CLI's offer.
707        Interface::Dashboard => match kind {
708            K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
709            K::Skill
710            | K::RunTask
711            | K::RunFeature
712            | K::Converge
713            | K::Commit
714            | K::Accept
715            | K::Skip
716            | K::Nudge
717            | K::Done
718            | K::Hint
719            | K::Rule
720            | K::Review
721            | K::IssueComment
722            | K::QuickChange
723            | K::KeepSetAside
724            | K::DropSetAside
725            | K::FixSetup
726            | K::PullRequest
727            | K::BugFix
728            | K::BugConfirm
729            | K::BugRun
730            | K::BugKeep
731            | K::WidenPolicy
732            | K::OracleTests
733            | K::CheckStory
734            | K::ApproveAndRun
735            | K::Comment
736            | K::CommentsSend
737            | K::RevisionUndo
738            | K::CodeNudge
739            | K::AcceptanceAt
740            | K::RuleScenario
741            | K::AcceptUntestable
742            | K::PresetAdd
743            | K::PlanThrough
744            | K::NewProject
745            | K::UndoNewProject
746            | K::CloneRepository
747            | K::OpenSample
748            | K::SampleChecks
749            | K::DeclinePromotion
750            | K::Explore
751            | K::ExploreChoose
752            | K::Command => Offer::Refuse,
753        },
754        Interface::CliRemedy | Interface::AcpRemedy => match kind {
755            K::FixSetup => Offer::Take,
756            K::Skill
757            | K::RunTask
758            | K::RunFeature
759            | K::Converge
760            | K::Commit
761            | K::Approve
762            | K::Reject
763            | K::Accept
764            | K::Skip
765            | K::Nudge
766            | K::Done
767            | K::Hint
768            | K::Rule
769            | K::Review
770            | K::IssueComment
771            | K::QuickChange
772            | K::KeepSetAside
773            | K::DropSetAside
774            | K::Feature
775            | K::PullRequest
776            | K::BugFix
777            | K::BugConfirm
778            | K::BugRun
779            | K::BugKeep
780            | K::AllowAlways
781            | K::WidenPolicy
782            | K::DecideBatch
783            | K::OracleTests
784            | K::CheckStory
785            | K::ApproveAndRun
786            | K::Comment
787            | K::CommentsSend
788            | K::RevisionUndo
789            | K::CodeNudge
790            | K::AcceptanceAt
791            | K::RuleScenario
792            | K::AcceptUntestable
793            | K::PresetAdd
794            | K::PlanThrough
795            | K::NewProject
796            | K::UndoNewProject
797            | K::CloneRepository
798            | K::OpenSample
799            | K::SampleChecks
800            | K::DeclinePromotion
801            | K::Explore
802            | K::ExploreChoose
803            | K::Command => Offer::Refuse,
804        },
805    }
806}
807
808/// An action to take: what runs, the person's input, the interface it
809/// came through and how that interface runs it (who it asks, where
810/// progress goes, its cancel flag).
811#[derive(Debug)]
812pub struct Dispatch<'a> {
813    /// What runs.
814    pub run: &'a Runnable,
815    /// The person's input (a reason, a note, a skill's arguments); blank
816    /// is none.
817    pub input: Option<&'a str>,
818    /// The interface, as what the person decides is recorded.
819    pub via: Via,
820    /// The interface's approver, progress and cancellation.
821    pub options: RunOptions,
822}
823
824impl<'a> Dispatch<'a> {
825    /// `run` through `via`, without input, asking nobody.
826    #[must_use]
827    pub fn new(run: &'a Runnable, via: Via) -> Self {
828        Self {
829            run,
830            input: None,
831            via,
832            options: RunOptions::default(),
833        }
834    }
835}
836
837/// [`perform`], if `interface` offers the action's kind; `None`, with
838/// nothing run, if it does not.
839#[must_use]
840pub fn perform_for(
841    interface: Interface,
842    root: &Path,
843    feature: Option<&str>,
844    d: Dispatch<'_>,
845) -> Option<CommandResult> {
846    (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
847}
848
849/// Takes the action: the only place a [`Runnable`] becomes runtime calls.
850/// It never prompts and never confirms (an interface has, before); what
851/// an interface does not offer it refuses before calling this
852/// ([`offered`]).
853#[must_use]
854pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
855    perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
856}
857
858/// `options` with this run's agent, when the action names one.
859fn with_agent(options: &RunOptions, agent: Option<&String>) -> RunOptions {
860    RunOptions {
861        agent: agent.cloned().or_else(|| options.agent.clone()),
862        ..options.clone()
863    }
864}
865
866/// [`perform`] with the state directory given (`None`: there is none).
867#[must_use]
868pub fn perform_in(
869    root: &Path,
870    feature: Option<&str>,
871    d: Dispatch<'_>,
872    state: Option<&Path>,
873) -> CommandResult {
874    use crate::approvals::{Choice, Verb};
875    let needs = |what: &str| {
876        CommandResult::new(
877            "job",
878            CommandStatus::UsageError,
879            &format!("{what} is needed"),
880        )
881    };
882    let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
883    let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
884    let options = &d.options;
885    let via = d.via;
886    let text = d.input.map(str::trim).filter(|t| !t.is_empty());
887    match d.run {
888        Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
889        Runnable::Run {
890            task: Some(t),
891            agent,
892        } => run_task(root, feature, t, &with_agent(options, agent.as_ref())),
893        Runnable::Run { task: None, agent } => {
894            run_feature(root, feature, &with_agent(options, agent.as_ref()), false)
895        }
896        Runnable::Converge => run_feature(root, feature, options, true),
897        Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
898        Runnable::Approve { task, call, digest } => decide(Verb::Decide(
899            Choice::approve(task, text).shown(call, digest),
900        )),
901        Runnable::Reject { task, call, digest } => match text {
902            Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
903            None => needs("a reason"),
904        },
905        Runnable::Accept { task } => match (text, state) {
906            (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
907            (None, _) => needs("a reason"),
908            (_, None) => no_state(),
909        },
910        // A decision recorded in the state directory, never the spec.
911        Runnable::AcceptanceAt {
912            feature: unit,
913            story,
914            task,
915            revoke,
916        } => match state {
917            Some(s) => {
918                crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
919            }
920            None => no_state(),
921        },
922        Runnable::RuleScenario { task, scenario } => match (text, state) {
923            (Some(why), Some(s)) => crate::acceptance_at::rule_tests_wrong(
924                root,
925                feature,
926                task,
927                scenario.as_deref(),
928                why,
929                via,
930                s,
931            ),
932            (None, _) => needs("a reason"),
933            (_, None) => no_state(),
934        },
935        Runnable::AcceptUntestable { scenario } => match (text, state) {
936            (Some(why), Some(s)) => {
937                crate::acceptance_at::accept_untestable(root, feature, scenario, why, via, s)
938            }
939            (None, _) => needs("a reason"),
940            (_, None) => no_state(),
941        },
942        // The interface has confirmed: Spec Kit's own command runs.
943        Runnable::PresetAdd { update } => match state {
944            Some(s) => crate::preset::add(root, *update, true, s),
945            None => no_state(),
946        },
947        Runnable::Skip { task } => match text {
948            Some(why) => crate::controls::skip(root, feature, task, Some(why)),
949            None => needs("a reason"),
950        },
951        Runnable::Nudge { task } => match (text, state) {
952            (Some(note), Some(s)) => {
953                crate::controls::nudge_with(root, feature, task, Some(note), s)
954            }
955            (None, _) => needs("a note"),
956            (_, None) => no_state(),
957        },
958        // I did it: the person's word, in the words they gave or these.
959        // In place when the dispatch is (an answer at an in-place run's stop).
960        Runnable::Done { task } => match state {
961            Some(s) => crate::controls::accept_in_session(
962                root,
963                feature,
964                task,
965                text.unwrap_or(DONE_REASON),
966                s,
967                options,
968            ),
969            None => no_state(),
970        },
971        // Hint & retry: the note first; a new attempt only once it is kept.
972        Runnable::Hint { task } => match (text, state) {
973            (Some(note), Some(s)) => {
974                let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
975                if noted.status() == CommandStatus::Ok {
976                    run_task(root, feature, task, options)
977                } else {
978                    noted
979                }
980            }
981            (None, _) => needs("a hint"),
982            (_, None) => no_state(),
983        },
984        Runnable::Rule { task } => match (text, state) {
985            (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
986            (None, _) => needs("a reason"),
987            (_, None) => no_state(),
988        },
989        Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
990            ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
991            ("edit" | "edit-approve", Some(id), Some(t)) => {
992                crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
993            }
994            ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
995            _ => crate::review::decide(root, feature, ids, verdict, text, via),
996        },
997        Runnable::IssueComment { issue, assign } => crate::github::comment(
998            &crate::github::Ctx::from_env(),
999            root,
1000            feature,
1001            &issue.to_string(),
1002            *assign,
1003        ),
1004        Runnable::QuickChange {
1005            request,
1006            checks,
1007            run,
1008            asked,
1009        } => quick_change(
1010            root,
1011            request,
1012            checks,
1013            *run,
1014            via,
1015            options,
1016            asked.as_deref().zip(state),
1017        ),
1018        Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
1019        Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
1020        Runnable::FixSetup => crate::code::fix_setup(root, feature),
1021        Runnable::Feature => match text {
1022            Some(name) => crate::current::switch(root, name),
1023            None => needs("a feature"),
1024        },
1025        Runnable::BugFix {
1026            symptom,
1027            expected,
1028            unchanged,
1029            issue,
1030            tests,
1031            candidate,
1032            slug,
1033            run,
1034            asked,
1035        } => bug_fix(
1036            root,
1037            &crate::bug::Request {
1038                asked: asked.clone(),
1039                ..crate::bug::Request::of(
1040                    symptom,
1041                    expected,
1042                    unchanged,
1043                    *issue,
1044                    tests,
1045                    candidate.as_deref(),
1046                    slug.as_deref(),
1047                    *run,
1048                )
1049            },
1050            via,
1051            state,
1052            options,
1053        ),
1054        Runnable::BugConfirm {
1055            task,
1056            digest,
1057            reject,
1058        } => match (*reject, text) {
1059            (true, None) => needs("a reason"),
1060            (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
1061            (false, _) => bug_confirm(root, task, digest, None, via, state, options),
1062        },
1063        Runnable::BugRun { slug, task } => {
1064            bug_run(root, slug.as_deref(), task.as_deref(), state, options)
1065        }
1066        Runnable::BugKeep { task } => match text {
1067            Some(why) => crate::bug::keep(root, task, why, state),
1068            None => needs("a reason"),
1069        },
1070        Runnable::AllowAlways { task, call } => {
1071            crate::approvals::allow_always(root, feature, task, call, state, via)
1072        }
1073        Runnable::WidenPolicy {
1074            key,
1075            prefix,
1076            digest,
1077        } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
1078        Runnable::DecideBatch {
1079            run,
1080            verdict,
1081            calls,
1082            digest,
1083            resume_asking,
1084        } => {
1085            let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
1086            if !approve && text.is_none() {
1087                return needs("a reason");
1088            }
1089            crate::approvals::decide_batch(
1090                root,
1091                feature,
1092                crate::approvals::BatchChoice {
1093                    run,
1094                    approve,
1095                    calls,
1096                    digest,
1097                    resume_asking: *resume_asking,
1098                    reason: text,
1099                },
1100                state,
1101                via,
1102            )
1103        }
1104        Runnable::CheckStory { story } => {
1105            crate::pipeline::story::check_story(root, feature, story, options)
1106        }
1107        Runnable::OracleTests {
1108            req,
1109            op,
1110            path,
1111            scenario,
1112            reading,
1113        } => crate::oracle::tests(
1114            root,
1115            feature,
1116            &crate::oracle::Request {
1117                req,
1118                op: *op,
1119                path: path.as_deref(),
1120                scenario: scenario.as_deref(),
1121                reading: *reading,
1122                text: d.input.filter(|t| !t.trim().is_empty()),
1123            },
1124            via,
1125            state,
1126            options,
1127        ),
1128        Runnable::ApproveAndRun {
1129            mode,
1130            grants,
1131            sheet,
1132        } => crate::review::approve_and_run(
1133            root,
1134            feature,
1135            *mode,
1136            grants,
1137            sheet.as_deref(),
1138            via,
1139            state,
1140            None,
1141            options,
1142        ),
1143        Runnable::Comment { op, anchor, id } => crate::comments::comment(
1144            root,
1145            feature,
1146            *op,
1147            anchor.as_ref(),
1148            id.as_deref(),
1149            text,
1150            via,
1151            state,
1152        ),
1153        Runnable::CommentsSend { ids } => {
1154            crate::comments::send(root, feature, ids, via, state, options)
1155        }
1156        Runnable::Explore {
1157            idea,
1158            skill,
1159            resume,
1160        } => crate::explore::explore(
1161            root,
1162            text.unwrap_or(idea),
1163            skill.as_deref(),
1164            resume.as_deref(),
1165            options,
1166        ),
1167        Runnable::ExploreChoose {
1168            session,
1169            approach,
1170            constraints,
1171            questions,
1172            flag,
1173        } => crate::explore::choose(
1174            root,
1175            state,
1176            session,
1177            *approach,
1178            constraints,
1179            questions,
1180            via,
1181            if *flag { "flag" } else { "prompt" },
1182        ),
1183        Runnable::RevisionUndo { turn } => {
1184            crate::comments::undo(root, feature, turn.as_deref(), state)
1185        }
1186        Runnable::CodeNudge { task, comments } => {
1187            crate::comments::code_nudge(root, feature, task, comments, state)
1188        }
1189        // The format's own skills, in its order; the stops are read from
1190        // the unit's files, so nothing of the chain is kept here.
1191        Runnable::PlanThrough {
1192            from:
1193                speccoder_core::actions::PlanFrom::Create {
1194                    description,
1195                    source,
1196                },
1197            through,
1198        } => {
1199            let description = if description.trim().is_empty() {
1200                text.unwrap_or_default()
1201            } else {
1202                description.as_str()
1203            };
1204            match source {
1205                // The conversations carried come from their own record;
1206                // the new unit's origin is written once it exists.
1207                speccoder_core::actions::FeatureSource::Promotion {
1208                    task,
1209                    conversations,
1210                } => {
1211                    let Some(s) = state else { return no_state() };
1212                    let input = crate::ladder::promotion_input(root, s, description, conversations);
1213                    let made = plan_through(root, &input, source, *through, options);
1214                    crate::ladder::promoted(
1215                        root,
1216                        s,
1217                        &made,
1218                        task,
1219                        conversations,
1220                        via,
1221                        options.progress.as_ref(),
1222                    );
1223                    made
1224                }
1225                // An Explore brief: verbatim and unchanged since it was
1226                // chosen, or nothing runs.
1227                speccoder_core::actions::FeatureSource::Explore { session, digest } => {
1228                    let Some(s) = state else { return no_state() };
1229                    if let Err(refused) =
1230                        crate::explore::verify_start(root, s, session, digest, description)
1231                    {
1232                        return *refused;
1233                    }
1234                    let made = plan_through(root, description, source, *through, options);
1235                    crate::explore::explored(root, s, &made, session, digest, via);
1236                    made
1237                }
1238                _ => plan_through(root, description, source, *through, options),
1239            }
1240        }
1241        Runnable::PlanThrough {
1242            from: speccoder_core::actions::PlanFrom::Continue,
1243            through,
1244        } => crate::planning_chain::resume(root, feature, *through, options),
1245        // The plan the person confirmed, recomputed and compared first.
1246        Runnable::NewProject {
1247            target,
1248            choices,
1249            plan_digest,
1250        } => match state {
1251            Some(s) => crate::new_project::perform(
1252                Path::new(target),
1253                choices,
1254                plan_digest,
1255                via,
1256                s,
1257                options,
1258            ),
1259            None => no_state(),
1260        },
1261        Runnable::UndoNewProject { commit } => match state {
1262            Some(s) => crate::new_project::undo(root, commit, via, s, options),
1263            None => no_state(),
1264        },
1265        // The interface has confirmed it names github.com.
1266        Runnable::CloneRepository { repo, target } => {
1267            crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1268        }
1269        Runnable::OpenSample { fresh } => match state {
1270            Some(s) => crate::sample::open(s, *fresh),
1271            None => no_state(),
1272        },
1273        Runnable::SampleChecks => match state {
1274            Some(s) => crate::sample::run_checks(s, root),
1275            None => no_state(),
1276        },
1277        Runnable::DeclinePromotion { task } => match state {
1278            Some(s) => crate::ladder::decline_task(root, s, task),
1279            None => no_state(),
1280        },
1281        // The interface has confirmed it (the action's `confirm`): push
1282        // and open.
1283        Runnable::PullRequest => crate::handoff::pr(
1284            root,
1285            feature,
1286            &crate::handoff::PrOptions {
1287                remote: "origin",
1288                base: None,
1289                open: true,
1290                force: false,
1291                github: &crate::github::Ctx::from_env(),
1292            },
1293        ),
1294    }
1295}
1296
1297/// The calls a person answered Always Allow inline: each one's exact argv
1298/// joins the allow list through [`perform`] with the interface it was
1299/// answered through. What each did, as notices.
1300#[must_use]
1301pub fn allow_always_inline(
1302    root: &Path,
1303    feature: &str,
1304    always: &[crate::substrate::Always],
1305    state: &Path,
1306) -> Vec<String> {
1307    always
1308        .iter()
1309        .map(|a| {
1310            let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1311            let run = Runnable::AllowAlways {
1312                task,
1313                call: a.call.clone(),
1314            };
1315            perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1316                .message()
1317                .to_owned()
1318        })
1319        .collect()
1320}
1321
1322/// The person's answer at a stop, given inline at their terminal, taken
1323/// through [`perform`] with the interface it was answered through: I did
1324/// it (`Done`, in the run's in-place session when it has one), the hint
1325/// (`Nudge`: the run then attempts the task again, with its own model) or
1326/// skip (`Skip`).
1327#[must_use]
1328pub fn answered_at_stop(
1329    root: &Path,
1330    feature: Option<&str>,
1331    task: &str,
1332    answer: &crate::approvals::StopAnswer,
1333    (via, run): (Via, &RunOptions),
1334    state: &Path,
1335) -> CommandResult {
1336    use crate::approvals::StopAnswer;
1337    let task = task.to_owned();
1338    let (what, input) = match answer {
1339        StopAnswer::Done => (Runnable::Done { task }, None),
1340        StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1341        StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1342    };
1343    perform_in(
1344        root,
1345        feature,
1346        Dispatch {
1347            input,
1348            options: RunOptions {
1349                in_place: run.in_place,
1350                session: run.session.clone(),
1351                ..RunOptions::default()
1352            },
1353            ..Dispatch::new(&what, via)
1354        },
1355        Some(state),
1356    )
1357}
1358
1359// The arms that run the model, one function each, so a build without the
1360// engine can leave them out without a second dispatcher.
1361
1362/// A planning skill: the person's input, else the arguments it carries.
1363fn skill(
1364    root: &Path,
1365    feature: Option<&str>,
1366    name: &str,
1367    input: Option<&str>,
1368    carried: &str,
1369    options: &RunOptions,
1370) -> CommandResult {
1371    crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1372}
1373
1374fn plan_through(
1375    root: &Path,
1376    description: &str,
1377    source: &speccoder_core::actions::FeatureSource,
1378    through: speccoder_core::actions::Through,
1379    options: &RunOptions,
1380) -> CommandResult {
1381    crate::planning_chain::start(root, description, source, through, options)
1382}
1383
1384fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1385    crate::run(root, feature, task, options)
1386}
1387
1388fn run_feature(
1389    root: &Path,
1390    feature: Option<&str>,
1391    options: &RunOptions,
1392    converge: bool,
1393) -> CommandResult {
1394    crate::run_feature(root, feature, options, converge)
1395}
1396
1397fn bug_fix(
1398    root: &Path,
1399    req: &crate::bug::Request,
1400    via: Via,
1401    state: Option<&Path>,
1402    options: &RunOptions,
1403) -> CommandResult {
1404    crate::bug::bug(root, req, via, state, options)
1405}
1406
1407fn bug_confirm(
1408    root: &Path,
1409    task: &str,
1410    digest: &str,
1411    reject: Option<&str>,
1412    via: Via,
1413    state: Option<&Path>,
1414    options: &RunOptions,
1415) -> CommandResult {
1416    crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1417}
1418
1419fn bug_run(
1420    root: &Path,
1421    slug: Option<&str>,
1422    task: Option<&str>,
1423    state: Option<&Path>,
1424    options: &RunOptions,
1425) -> CommandResult {
1426    crate::bug::run(root, slug, task, state, options)
1427}
1428
1429fn quick_change(
1430    root: &Path,
1431    request: &str,
1432    checks: &[String],
1433    run: bool,
1434    via: Via,
1435    options: &RunOptions,
1436    asked: Option<(&str, &Path)>,
1437) -> CommandResult {
1438    crate::quick::change(root, request, checks, run, via, options, asked)
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443    use speccoder_core::actions::primary;
1444
1445    use super::*;
1446
1447    #[test]
1448    fn every_interface_decides_every_kind() {
1449        let mut taken = 0;
1450        for i in Interface::ALL {
1451            for k in ActionKind::ALL {
1452                if offered(*i, *k) == Offer::Take {
1453                    taken += 1;
1454                } else {
1455                    assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1456                }
1457            }
1458        }
1459        // The web host runs every runnable kind; the terminal the steps
1460        // it offers (plan through's continuation among them); the remedies
1461        // only the setup's fix.
1462        let runnable = Runnable::examples().len();
1463        // The dashboard also decides Always Allow and batches; an Ask
1464        // proposal is one of five kinds.
1465        assert_eq!(taken, runnable + 6 + 5 + 2 + 5);
1466        assert_eq!(
1467            offered(Interface::CliFollow, ActionKind::PullRequest),
1468            Offer::Refuse
1469        );
1470        assert_eq!(
1471            offered(Interface::AcpRemedy, ActionKind::FixSetup),
1472            Offer::Take
1473        );
1474    }
1475
1476    /// The speccoder commands no interface takes: developer tools, and a
1477    /// pull request to a remote only the person can name.
1478    fn copied(line: &str) -> bool {
1479        let mut w = line.split_whitespace().skip(1);
1480        matches!(w.next(), Some("eval" | "replay" | "bench"))
1481            || line.contains("--remote <")
1482            // Another project's command: run there.
1483            || line.split_whitespace().any(|w| w == "-C")
1484            // A line about a command's own flags (`{command} --help`): said.
1485            || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1486                && line.split_whitespace().count() == 3
1487    }
1488
1489    /// String literals that start `speccoder ` and are a message's prose,
1490    /// not a command (the start of each). A new one is added here only
1491    /// when it is no command.
1492    const PROSE: &[&str] = &[
1493        "speccoder did not ",
1494        "speccoder cannot ",
1495        "speccoder commits ",
1496        "speccoder could ",
1497        "speccoder failed ",
1498        "speccoder has ",
1499        "speccoder is ",
1500        "speccoder needs ",
1501        "speccoder reads ",
1502        "speccoder works ",
1503        "speccoder does not ",
1504        "speccoder implements ",
1505        "speccoder converges ",
1506        "speccoder runs ",
1507        "speccoder Documentation",
1508        "speccoder — ",
1509        "speccoder ✓",
1510        "speccoder ■",
1511        "speccoder ▸",
1512        "speccoder △",
1513        "speccoder ✗",
1514        "speccoder / ",
1515        "speccoder attempt: ",
1516        "speccoder manifest: ",
1517        "speccoder inspect: ",
1518        "speccoder preset ",
1519        "speccoder bundles",
1520        "speccoder {}: ",
1521        "speccoder {feature}: ",
1522        "speccoder 001-x: ",
1523        "speccoder {} · ",
1524        // The command the person typed, echoed.
1525        "speccoder {} --yes",
1526    ];
1527
1528    /// Every `speccoder …` command the catalog and every builder print
1529    /// (each string literal of the core, runtime, web and CLI sources,
1530    /// placeholders filled) is performed, opened as a view or run as one
1531    /// of speccoder's own commands — never only copied, and never words
1532    /// the parser could not read.
1533    #[test]
1534    fn no_speccoder_command_is_copy_only() {
1535        let ctx = Context {
1536            feature: Some("001-x"),
1537            task: Some("T001"),
1538        };
1539        let mut seen = 0;
1540        let mut errors = Vec::new();
1541        for e in speccoder_core::codes::CATALOG {
1542            for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1543                if line.starts_with("speccoder ") && !copied(&line) {
1544                    let how = runnable_of(&line, ctx).how;
1545                    if how.copy_only() {
1546                        errors.push(format!("{}: {line} → {how:?}", e.name));
1547                    }
1548                    seen += 1;
1549                }
1550            }
1551        }
1552        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1553        let mut files = Vec::new();
1554        for dir in [
1555            "src",
1556            "../speccoder-core/src",
1557            "../speccoder-web/src",
1558            "../speccoder/src",
1559        ] {
1560            let mut stack = vec![root.join(dir)];
1561            while let Some(d) = stack.pop() {
1562                for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1563                    let p = entry.path();
1564                    if p.is_dir() {
1565                        stack.push(p);
1566                    } else if p.extension().is_some_and(|x| x == "rs") {
1567                        files.push(p);
1568                    }
1569                }
1570            }
1571        }
1572        assert!(files.len() > 100, "{} files", files.len());
1573        for f in files {
1574            let src = std::fs::read_to_string(&f).unwrap_or_default();
1575            // A test's lines are its cases, not what speccoder prints.
1576            let src = src
1577                .split("#[cfg(test)]\nmod tests")
1578                .next()
1579                .unwrap_or_default();
1580            for (at, _) in src.match_indices("\"speccoder ") {
1581                // A quote within another literal is no literal.
1582                if src[..at].ends_with('\\') {
1583                    continue;
1584                }
1585                let mut line = String::new();
1586                let mut chars = src[at + 1..].chars();
1587                while let Some(c) = chars.next() {
1588                    match c {
1589                        '\\' => line.extend(chars.next()),
1590                        '"' => break,
1591                        c => line.push(c),
1592                    }
1593                }
1594                // A format string's arguments, filled.
1595                let mut filled = String::new();
1596                let mut rest = line.as_str();
1597                while let Some(open) = rest.find('{') {
1598                    filled.push_str(&rest[..open]);
1599                    let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1600                    // A suffix (`doctor{f}`: ` --feature F` or nothing).
1601                    let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1602                    filled.push_str(match &rest[open..close] {
1603                        _ if suffix => "",
1604                        "{task}" | "{t}" => "T001",
1605                        "{feature}" | "{f}" => "001-x",
1606                        "{command}" | "{verb}" => "run",
1607                        _ if filled.ends_with("--through ") => "tasks",
1608                        _ => "x",
1609                    });
1610                    rest = &rest[close.min(rest.len())..];
1611                }
1612                filled.push_str(rest);
1613                // A prefix compared against, no line.
1614                if line == "speccoder "
1615                    || copied(&filled)
1616                    || PROSE.iter().any(|p| line.starts_with(p))
1617                {
1618                    continue;
1619                }
1620                let how = runnable_of(&filled, ctx).how;
1621                if how.copy_only() {
1622                    errors.push(format!("{}: {filled} → {how:?}", f.display()));
1623                }
1624                seen += 1;
1625            }
1626        }
1627        assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1628        assert!(seen > 100, "{seen} lines seen");
1629    }
1630
1631    #[test]
1632    fn blank_input_is_no_input() {
1633        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1634        let run = Runnable::Reject {
1635            task: "T001".to_owned(),
1636            call: "attempt:T001".to_owned(),
1637            digest: String::new(),
1638        };
1639        let r = perform_in(
1640            dir.path(),
1641            None,
1642            Dispatch {
1643                run: &run,
1644                input: Some("  "),
1645                via: Via::Web,
1646                options: RunOptions::default(),
1647            },
1648            None,
1649        );
1650        assert_eq!(
1651            (r.command(), r.status(), r.message()),
1652            ("job", CommandStatus::UsageError, "a reason is needed")
1653        );
1654    }
1655
1656    #[test]
1657    fn a_project_outside_git_still_gets_a_list() {
1658        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1659        let list = available(dir.path(), None, None);
1660        assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1661        assert!(primary(&list).is_none());
1662    }
1663}