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::PresetAdd
593            | K::PlanThrough
594            | K::NewProject
595            | K::UndoNewProject
596            | K::CloneRepository
597            | K::OpenSample
598            | K::SampleChecks
599            | K::DeclinePromotion
600            | K::Explore
601            | K::ExploreChoose => Offer::Take,
602            K::Command => Offer::Refuse,
603        },
604        // Decisions belong to their own prompt; what leaves the machine
605        // or asks for words is the person's own command there.
606        Interface::CliFollow => match kind {
607            // Plan through's continuation is the `Next:` step after a stop.
608            K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
609                Offer::Take
610            }
611            K::Approve
612            | K::Reject
613            | K::Accept
614            | K::Skip
615            | K::Nudge
616            | K::Done
617            | K::Hint
618            | K::Rule
619            | K::Review
620            | K::IssueComment
621            | K::QuickChange
622            | K::KeepSetAside
623            | K::DropSetAside
624            | K::FixSetup
625            | K::Feature
626            | K::PullRequest
627            | K::BugFix
628            | K::BugConfirm
629            | K::BugRun
630            | K::BugKeep
631            | K::AllowAlways
632            | K::WidenPolicy
633            | K::DecideBatch
634            | K::OracleTests
635            | K::CheckStory
636            | K::ApproveAndRun
637            | K::Comment
638            | K::CommentsSend
639            | K::RevisionUndo
640            | K::CodeNudge
641            | K::AcceptanceAt
642            | K::RuleScenario
643            | K::PresetAdd
644            | K::NewProject
645            | K::UndoNewProject
646            | K::CloneRepository
647            | K::OpenSample
648            | K::SampleChecks
649            | K::DeclinePromotion
650            | K::Explore
651            | K::ExploreChoose
652            | K::Command => Offer::Refuse,
653        },
654        // What a proposal may be: a quick change, a bug fix, a nudge, an
655        // answer to an open question, a new spec. Nothing else.
656        Interface::AskProposal => match kind {
657            K::QuickChange | K::BugFix | K::Nudge | K::Review | K::PlanThrough => Offer::Take,
658            K::Skill
659            | K::RunTask
660            | K::RunFeature
661            | K::Converge
662            | K::Commit
663            | K::Approve
664            | K::Reject
665            | K::Accept
666            | K::Skip
667            | K::Done
668            | K::Hint
669            | K::Rule
670            | K::IssueComment
671            | K::KeepSetAside
672            | K::DropSetAside
673            | K::FixSetup
674            | K::Feature
675            | K::PullRequest
676            | K::BugConfirm
677            | K::BugRun
678            | K::BugKeep
679            | K::AllowAlways
680            | K::WidenPolicy
681            | K::DecideBatch
682            | K::OracleTests
683            | K::CheckStory
684            | K::ApproveAndRun
685            | K::Comment
686            | K::CommentsSend
687            | K::RevisionUndo
688            | K::CodeNudge
689            | K::AcceptanceAt
690            | K::RuleScenario
691            | K::PresetAdd
692            | K::NewProject
693            | K::UndoNewProject
694            | K::CloneRepository
695            | K::OpenSample
696            | K::SampleChecks
697            | K::DeclinePromotion
698            | K::Explore
699            | K::ExploreChoose
700            | K::Command => Offer::Refuse,
701        },
702        // Its keys decide the queue's calls and switch the feature; `n`
703        // hands the next step to the line CLI's offer.
704        Interface::Dashboard => match kind {
705            K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
706            K::Skill
707            | K::RunTask
708            | K::RunFeature
709            | K::Converge
710            | K::Commit
711            | K::Accept
712            | K::Skip
713            | K::Nudge
714            | K::Done
715            | K::Hint
716            | K::Rule
717            | K::Review
718            | K::IssueComment
719            | K::QuickChange
720            | K::KeepSetAside
721            | K::DropSetAside
722            | K::FixSetup
723            | K::PullRequest
724            | K::BugFix
725            | K::BugConfirm
726            | K::BugRun
727            | K::BugKeep
728            | K::WidenPolicy
729            | K::OracleTests
730            | K::CheckStory
731            | K::ApproveAndRun
732            | K::Comment
733            | K::CommentsSend
734            | K::RevisionUndo
735            | K::CodeNudge
736            | K::AcceptanceAt
737            | K::RuleScenario
738            | K::PresetAdd
739            | K::PlanThrough
740            | K::NewProject
741            | K::UndoNewProject
742            | K::CloneRepository
743            | K::OpenSample
744            | K::SampleChecks
745            | K::DeclinePromotion
746            | K::Explore
747            | K::ExploreChoose
748            | K::Command => Offer::Refuse,
749        },
750        Interface::CliRemedy | Interface::AcpRemedy => match kind {
751            K::FixSetup => Offer::Take,
752            K::Skill
753            | K::RunTask
754            | K::RunFeature
755            | K::Converge
756            | K::Commit
757            | K::Approve
758            | K::Reject
759            | K::Accept
760            | K::Skip
761            | K::Nudge
762            | K::Done
763            | K::Hint
764            | K::Rule
765            | K::Review
766            | K::IssueComment
767            | K::QuickChange
768            | K::KeepSetAside
769            | K::DropSetAside
770            | K::Feature
771            | K::PullRequest
772            | K::BugFix
773            | K::BugConfirm
774            | K::BugRun
775            | K::BugKeep
776            | K::AllowAlways
777            | K::WidenPolicy
778            | K::DecideBatch
779            | K::OracleTests
780            | K::CheckStory
781            | K::ApproveAndRun
782            | K::Comment
783            | K::CommentsSend
784            | K::RevisionUndo
785            | K::CodeNudge
786            | K::AcceptanceAt
787            | K::RuleScenario
788            | K::PresetAdd
789            | K::PlanThrough
790            | K::NewProject
791            | K::UndoNewProject
792            | K::CloneRepository
793            | K::OpenSample
794            | K::SampleChecks
795            | K::DeclinePromotion
796            | K::Explore
797            | K::ExploreChoose
798            | K::Command => Offer::Refuse,
799        },
800    }
801}
802
803/// An action to take: what runs, the person's input, the interface it
804/// came through and how that interface runs it (who it asks, where
805/// progress goes, its cancel flag).
806#[derive(Debug)]
807pub struct Dispatch<'a> {
808    /// What runs.
809    pub run: &'a Runnable,
810    /// The person's input (a reason, a note, a skill's arguments); blank
811    /// is none.
812    pub input: Option<&'a str>,
813    /// The interface, as what the person decides is recorded.
814    pub via: Via,
815    /// The interface's approver, progress and cancellation.
816    pub options: RunOptions,
817}
818
819impl<'a> Dispatch<'a> {
820    /// `run` through `via`, without input, asking nobody.
821    #[must_use]
822    pub fn new(run: &'a Runnable, via: Via) -> Self {
823        Self {
824            run,
825            input: None,
826            via,
827            options: RunOptions::default(),
828        }
829    }
830}
831
832/// [`perform`], if `interface` offers the action's kind; `None`, with
833/// nothing run, if it does not.
834#[must_use]
835pub fn perform_for(
836    interface: Interface,
837    root: &Path,
838    feature: Option<&str>,
839    d: Dispatch<'_>,
840) -> Option<CommandResult> {
841    (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
842}
843
844/// Takes the action: the only place a [`Runnable`] becomes runtime calls.
845/// It never prompts and never confirms (an interface has, before); what
846/// an interface does not offer it refuses before calling this
847/// ([`offered`]).
848#[must_use]
849pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
850    perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
851}
852
853/// `options` with this run's agent, when the action names one.
854fn with_agent(options: &RunOptions, agent: Option<&String>) -> RunOptions {
855    RunOptions {
856        agent: agent.cloned().or_else(|| options.agent.clone()),
857        ..options.clone()
858    }
859}
860
861/// [`perform`] with the state directory given (`None`: there is none).
862#[must_use]
863pub fn perform_in(
864    root: &Path,
865    feature: Option<&str>,
866    d: Dispatch<'_>,
867    state: Option<&Path>,
868) -> CommandResult {
869    use crate::approvals::{Choice, Verb};
870    let needs = |what: &str| {
871        CommandResult::new(
872            "job",
873            CommandStatus::UsageError,
874            &format!("{what} is needed"),
875        )
876    };
877    let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
878    let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
879    let options = &d.options;
880    let via = d.via;
881    let text = d.input.map(str::trim).filter(|t| !t.is_empty());
882    match d.run {
883        Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
884        Runnable::Run {
885            task: Some(t),
886            agent,
887        } => run_task(root, feature, t, &with_agent(options, agent.as_ref())),
888        Runnable::Run { task: None, agent } => {
889            run_feature(root, feature, &with_agent(options, agent.as_ref()), false)
890        }
891        Runnable::Converge => run_feature(root, feature, options, true),
892        Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
893        Runnable::Approve { task, call, digest } => decide(Verb::Decide(
894            Choice::approve(task, text).shown(call, digest),
895        )),
896        Runnable::Reject { task, call, digest } => match text {
897            Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
898            None => needs("a reason"),
899        },
900        Runnable::Accept { task } => match (text, state) {
901            (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
902            (None, _) => needs("a reason"),
903            (_, None) => no_state(),
904        },
905        // A decision recorded in the state directory, never the spec.
906        Runnable::AcceptanceAt {
907            feature: unit,
908            story,
909            task,
910            revoke,
911        } => match state {
912            Some(s) => {
913                crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
914            }
915            None => no_state(),
916        },
917        Runnable::RuleScenario { task, scenario } => match (text, state) {
918            (Some(why), Some(s)) => crate::acceptance_at::rule_tests_wrong(
919                root,
920                feature,
921                task,
922                scenario.as_deref(),
923                why,
924                via,
925                s,
926            ),
927            (None, _) => needs("a reason"),
928            (_, None) => no_state(),
929        },
930        // The interface has confirmed: Spec Kit's own command runs.
931        Runnable::PresetAdd { update } => match state {
932            Some(s) => crate::preset::add(root, *update, true, s),
933            None => no_state(),
934        },
935        Runnable::Skip { task } => match text {
936            Some(why) => crate::controls::skip(root, feature, task, Some(why)),
937            None => needs("a reason"),
938        },
939        Runnable::Nudge { task } => match (text, state) {
940            (Some(note), Some(s)) => {
941                crate::controls::nudge_with(root, feature, task, Some(note), s)
942            }
943            (None, _) => needs("a note"),
944            (_, None) => no_state(),
945        },
946        // I did it: the person's word, in the words they gave or these.
947        // In place when the dispatch is (an answer at an in-place run's stop).
948        Runnable::Done { task } => match state {
949            Some(s) => crate::controls::accept_in_session(
950                root,
951                feature,
952                task,
953                text.unwrap_or(DONE_REASON),
954                s,
955                options,
956            ),
957            None => no_state(),
958        },
959        // Hint & retry: the note first; a new attempt only once it is kept.
960        Runnable::Hint { task } => match (text, state) {
961            (Some(note), Some(s)) => {
962                let noted = crate::controls::nudge_with(root, feature, task, Some(note), s);
963                if noted.status() == CommandStatus::Ok {
964                    run_task(root, feature, task, options)
965                } else {
966                    noted
967                }
968            }
969            (None, _) => needs("a hint"),
970            (_, None) => no_state(),
971        },
972        Runnable::Rule { task } => match (text, state) {
973            (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
974            (None, _) => needs("a reason"),
975            (_, None) => no_state(),
976        },
977        Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
978            ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
979            ("edit" | "edit-approve", Some(id), Some(t)) => {
980                crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
981            }
982            ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
983            _ => crate::review::decide(root, feature, ids, verdict, text, via),
984        },
985        Runnable::IssueComment { issue, assign } => crate::github::comment(
986            &crate::github::Ctx::from_env(),
987            root,
988            feature,
989            &issue.to_string(),
990            *assign,
991        ),
992        Runnable::QuickChange {
993            request,
994            checks,
995            run,
996            asked,
997        } => quick_change(
998            root,
999            request,
1000            checks,
1001            *run,
1002            via,
1003            options,
1004            asked.as_deref().zip(state),
1005        ),
1006        Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
1007        Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
1008        Runnable::FixSetup => crate::code::fix_setup(root, feature),
1009        Runnable::Feature => match text {
1010            Some(name) => crate::current::switch(root, name),
1011            None => needs("a feature"),
1012        },
1013        Runnable::BugFix {
1014            symptom,
1015            expected,
1016            unchanged,
1017            issue,
1018            tests,
1019            candidate,
1020            slug,
1021            run,
1022            asked,
1023        } => bug_fix(
1024            root,
1025            &crate::bug::Request {
1026                asked: asked.clone(),
1027                ..crate::bug::Request::of(
1028                    symptom,
1029                    expected,
1030                    unchanged,
1031                    *issue,
1032                    tests,
1033                    candidate.as_deref(),
1034                    slug.as_deref(),
1035                    *run,
1036                )
1037            },
1038            via,
1039            state,
1040            options,
1041        ),
1042        Runnable::BugConfirm {
1043            task,
1044            digest,
1045            reject,
1046        } => match (*reject, text) {
1047            (true, None) => needs("a reason"),
1048            (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
1049            (false, _) => bug_confirm(root, task, digest, None, via, state, options),
1050        },
1051        Runnable::BugRun { slug, task } => {
1052            bug_run(root, slug.as_deref(), task.as_deref(), state, options)
1053        }
1054        Runnable::BugKeep { task } => match text {
1055            Some(why) => crate::bug::keep(root, task, why, state),
1056            None => needs("a reason"),
1057        },
1058        Runnable::AllowAlways { task, call } => {
1059            crate::approvals::allow_always(root, feature, task, call, state, via)
1060        }
1061        Runnable::WidenPolicy {
1062            key,
1063            prefix,
1064            digest,
1065        } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
1066        Runnable::DecideBatch {
1067            run,
1068            verdict,
1069            calls,
1070            digest,
1071            resume_asking,
1072        } => {
1073            let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
1074            if !approve && text.is_none() {
1075                return needs("a reason");
1076            }
1077            crate::approvals::decide_batch(
1078                root,
1079                feature,
1080                crate::approvals::BatchChoice {
1081                    run,
1082                    approve,
1083                    calls,
1084                    digest,
1085                    resume_asking: *resume_asking,
1086                    reason: text,
1087                },
1088                state,
1089                via,
1090            )
1091        }
1092        Runnable::CheckStory { story } => {
1093            crate::pipeline::story::check_story(root, feature, story, options)
1094        }
1095        Runnable::OracleTests {
1096            req,
1097            op,
1098            path,
1099            scenario,
1100            reading,
1101        } => crate::oracle::tests(
1102            root,
1103            feature,
1104            &crate::oracle::Request {
1105                req,
1106                op: *op,
1107                path: path.as_deref(),
1108                scenario: scenario.as_deref(),
1109                reading: *reading,
1110                text: d.input.filter(|t| !t.trim().is_empty()),
1111            },
1112            via,
1113            state,
1114            options,
1115        ),
1116        Runnable::ApproveAndRun {
1117            mode,
1118            grants,
1119            sheet,
1120        } => crate::review::approve_and_run(
1121            root,
1122            feature,
1123            *mode,
1124            grants,
1125            sheet.as_deref(),
1126            via,
1127            state,
1128            None,
1129            options,
1130        ),
1131        Runnable::Comment { op, anchor, id } => crate::comments::comment(
1132            root,
1133            feature,
1134            *op,
1135            anchor.as_ref(),
1136            id.as_deref(),
1137            text,
1138            via,
1139            state,
1140        ),
1141        Runnable::CommentsSend { ids } => {
1142            crate::comments::send(root, feature, ids, via, state, options)
1143        }
1144        Runnable::Explore {
1145            idea,
1146            skill,
1147            resume,
1148        } => crate::explore::explore(
1149            root,
1150            text.unwrap_or(idea),
1151            skill.as_deref(),
1152            resume.as_deref(),
1153            options,
1154        ),
1155        Runnable::ExploreChoose {
1156            session,
1157            approach,
1158            constraints,
1159            questions,
1160            flag,
1161        } => crate::explore::choose(
1162            root,
1163            state,
1164            session,
1165            *approach,
1166            constraints,
1167            questions,
1168            via,
1169            if *flag { "flag" } else { "prompt" },
1170        ),
1171        Runnable::RevisionUndo { turn } => {
1172            crate::comments::undo(root, feature, turn.as_deref(), state)
1173        }
1174        Runnable::CodeNudge { task, comments } => {
1175            crate::comments::code_nudge(root, feature, task, comments, state)
1176        }
1177        // The format's own skills, in its order; the stops are read from
1178        // the unit's files, so nothing of the chain is kept here.
1179        Runnable::PlanThrough {
1180            from:
1181                speccoder_core::actions::PlanFrom::Create {
1182                    description,
1183                    source,
1184                },
1185            through,
1186        } => {
1187            let description = if description.trim().is_empty() {
1188                text.unwrap_or_default()
1189            } else {
1190                description.as_str()
1191            };
1192            match source {
1193                // The conversations carried come from their own record;
1194                // the new unit's origin is written once it exists.
1195                speccoder_core::actions::FeatureSource::Promotion {
1196                    task,
1197                    conversations,
1198                } => {
1199                    let Some(s) = state else { return no_state() };
1200                    let input = crate::ladder::promotion_input(root, s, description, conversations);
1201                    let made = plan_through(root, &input, source, *through, options);
1202                    crate::ladder::promoted(
1203                        root,
1204                        s,
1205                        &made,
1206                        task,
1207                        conversations,
1208                        via,
1209                        options.progress.as_ref(),
1210                    );
1211                    made
1212                }
1213                // An Explore brief: verbatim and unchanged since it was
1214                // chosen, or nothing runs.
1215                speccoder_core::actions::FeatureSource::Explore { session, digest } => {
1216                    let Some(s) = state else { return no_state() };
1217                    if let Err(refused) =
1218                        crate::explore::verify_start(root, s, session, digest, description)
1219                    {
1220                        return *refused;
1221                    }
1222                    let made = plan_through(root, description, source, *through, options);
1223                    crate::explore::explored(root, s, &made, session, digest, via);
1224                    made
1225                }
1226                _ => plan_through(root, description, source, *through, options),
1227            }
1228        }
1229        Runnable::PlanThrough {
1230            from: speccoder_core::actions::PlanFrom::Continue,
1231            through,
1232        } => crate::planning_chain::resume(root, feature, *through, options),
1233        // The plan the person confirmed, recomputed and compared first.
1234        Runnable::NewProject {
1235            target,
1236            choices,
1237            plan_digest,
1238        } => match state {
1239            Some(s) => crate::new_project::perform(
1240                Path::new(target),
1241                choices,
1242                plan_digest,
1243                via,
1244                s,
1245                options,
1246            ),
1247            None => no_state(),
1248        },
1249        Runnable::UndoNewProject { commit } => match state {
1250            Some(s) => crate::new_project::undo(root, commit, via, s, options),
1251            None => no_state(),
1252        },
1253        // The interface has confirmed it names github.com.
1254        Runnable::CloneRepository { repo, target } => {
1255            crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
1256        }
1257        Runnable::OpenSample { fresh } => match state {
1258            Some(s) => crate::sample::open(s, *fresh),
1259            None => no_state(),
1260        },
1261        Runnable::SampleChecks => match state {
1262            Some(s) => crate::sample::run_checks(s, root),
1263            None => no_state(),
1264        },
1265        Runnable::DeclinePromotion { task } => match state {
1266            Some(s) => crate::ladder::decline_task(root, s, task),
1267            None => no_state(),
1268        },
1269        // The interface has confirmed it (the action's `confirm`): push
1270        // and open.
1271        Runnable::PullRequest => crate::handoff::pr(
1272            root,
1273            feature,
1274            &crate::handoff::PrOptions {
1275                remote: "origin",
1276                base: None,
1277                open: true,
1278                force: false,
1279                github: &crate::github::Ctx::from_env(),
1280            },
1281        ),
1282    }
1283}
1284
1285/// The calls a person answered Always Allow inline: each one's exact argv
1286/// joins the allow list through [`perform`] with the interface it was
1287/// answered through. What each did, as notices.
1288#[must_use]
1289pub fn allow_always_inline(
1290    root: &Path,
1291    feature: &str,
1292    always: &[crate::substrate::Always],
1293    state: &Path,
1294) -> Vec<String> {
1295    always
1296        .iter()
1297        .map(|a| {
1298            let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1299            let run = Runnable::AllowAlways {
1300                task,
1301                call: a.call.clone(),
1302            };
1303            perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1304                .message()
1305                .to_owned()
1306        })
1307        .collect()
1308}
1309
1310/// The person's answer at a stop, given inline at their terminal, taken
1311/// through [`perform`] with the interface it was answered through: I did
1312/// it (`Done`, in the run's in-place session when it has one), the hint
1313/// (`Nudge`: the run then attempts the task again, with its own model) or
1314/// skip (`Skip`).
1315#[must_use]
1316pub fn answered_at_stop(
1317    root: &Path,
1318    feature: Option<&str>,
1319    task: &str,
1320    answer: &crate::approvals::StopAnswer,
1321    (via, run): (Via, &RunOptions),
1322    state: &Path,
1323) -> CommandResult {
1324    use crate::approvals::StopAnswer;
1325    let task = task.to_owned();
1326    let (what, input) = match answer {
1327        StopAnswer::Done => (Runnable::Done { task }, None),
1328        StopAnswer::Hint(note) => (Runnable::Nudge { task }, Some(note.as_str())),
1329        StopAnswer::Skip(why) => (Runnable::Skip { task }, Some(why.as_str())),
1330    };
1331    perform_in(
1332        root,
1333        feature,
1334        Dispatch {
1335            input,
1336            options: RunOptions {
1337                in_place: run.in_place,
1338                session: run.session.clone(),
1339                ..RunOptions::default()
1340            },
1341            ..Dispatch::new(&what, via)
1342        },
1343        Some(state),
1344    )
1345}
1346
1347// The arms that run the model, one function each, so a build without the
1348// engine can leave them out without a second dispatcher.
1349
1350/// A planning skill: the person's input, else the arguments it carries.
1351fn skill(
1352    root: &Path,
1353    feature: Option<&str>,
1354    name: &str,
1355    input: Option<&str>,
1356    carried: &str,
1357    options: &RunOptions,
1358) -> CommandResult {
1359    crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1360}
1361
1362fn plan_through(
1363    root: &Path,
1364    description: &str,
1365    source: &speccoder_core::actions::FeatureSource,
1366    through: speccoder_core::actions::Through,
1367    options: &RunOptions,
1368) -> CommandResult {
1369    crate::planning_chain::start(root, description, source, through, options)
1370}
1371
1372fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1373    crate::run(root, feature, task, options)
1374}
1375
1376fn run_feature(
1377    root: &Path,
1378    feature: Option<&str>,
1379    options: &RunOptions,
1380    converge: bool,
1381) -> CommandResult {
1382    crate::run_feature(root, feature, options, converge)
1383}
1384
1385fn bug_fix(
1386    root: &Path,
1387    req: &crate::bug::Request,
1388    via: Via,
1389    state: Option<&Path>,
1390    options: &RunOptions,
1391) -> CommandResult {
1392    crate::bug::bug(root, req, via, state, options)
1393}
1394
1395fn bug_confirm(
1396    root: &Path,
1397    task: &str,
1398    digest: &str,
1399    reject: Option<&str>,
1400    via: Via,
1401    state: Option<&Path>,
1402    options: &RunOptions,
1403) -> CommandResult {
1404    crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1405}
1406
1407fn bug_run(
1408    root: &Path,
1409    slug: Option<&str>,
1410    task: Option<&str>,
1411    state: Option<&Path>,
1412    options: &RunOptions,
1413) -> CommandResult {
1414    crate::bug::run(root, slug, task, state, options)
1415}
1416
1417fn quick_change(
1418    root: &Path,
1419    request: &str,
1420    checks: &[String],
1421    run: bool,
1422    via: Via,
1423    options: &RunOptions,
1424    asked: Option<(&str, &Path)>,
1425) -> CommandResult {
1426    crate::quick::change(root, request, checks, run, via, options, asked)
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431    use speccoder_core::actions::primary;
1432
1433    use super::*;
1434
1435    #[test]
1436    fn every_interface_decides_every_kind() {
1437        let mut taken = 0;
1438        for i in Interface::ALL {
1439            for k in ActionKind::ALL {
1440                if offered(*i, *k) == Offer::Take {
1441                    taken += 1;
1442                } else {
1443                    assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1444                }
1445            }
1446        }
1447        // The web host runs every runnable kind; the terminal the steps
1448        // it offers (plan through's continuation among them); the remedies
1449        // only the setup's fix.
1450        let runnable = Runnable::examples().len();
1451        // The dashboard also decides Always Allow and batches; an Ask
1452        // proposal is one of five kinds.
1453        assert_eq!(taken, runnable + 6 + 5 + 2 + 5);
1454        assert_eq!(
1455            offered(Interface::CliFollow, ActionKind::PullRequest),
1456            Offer::Refuse
1457        );
1458        assert_eq!(
1459            offered(Interface::AcpRemedy, ActionKind::FixSetup),
1460            Offer::Take
1461        );
1462    }
1463
1464    /// The speccoder commands no interface takes: developer tools, and a
1465    /// pull request to a remote only the person can name.
1466    fn copied(line: &str) -> bool {
1467        let mut w = line.split_whitespace().skip(1);
1468        matches!(w.next(), Some("eval" | "replay" | "bench"))
1469            || line.contains("--remote <")
1470            // Another project's command: run there.
1471            || line.split_whitespace().any(|w| w == "-C")
1472            // A line about a command's own flags (`{command} --help`): said.
1473            || line.split_whitespace().any(|w| matches!(w, "--help" | "--json" | "--events"))
1474                && line.split_whitespace().count() == 3
1475    }
1476
1477    /// String literals that start `speccoder ` and are a message's prose,
1478    /// not a command (the start of each). A new one is added here only
1479    /// when it is no command.
1480    const PROSE: &[&str] = &[
1481        "speccoder did not ",
1482        "speccoder cannot ",
1483        "speccoder commits ",
1484        "speccoder could ",
1485        "speccoder failed ",
1486        "speccoder has ",
1487        "speccoder is ",
1488        "speccoder needs ",
1489        "speccoder reads ",
1490        "speccoder works ",
1491        "speccoder does not ",
1492        "speccoder implements ",
1493        "speccoder converges ",
1494        "speccoder runs ",
1495        "speccoder Documentation",
1496        "speccoder — ",
1497        "speccoder ✓",
1498        "speccoder ■",
1499        "speccoder ▸",
1500        "speccoder △",
1501        "speccoder ✗",
1502        "speccoder / ",
1503        "speccoder attempt: ",
1504        "speccoder manifest: ",
1505        "speccoder inspect: ",
1506        "speccoder preset ",
1507        "speccoder bundles",
1508        "speccoder {}: ",
1509        "speccoder {feature}: ",
1510        "speccoder 001-x: ",
1511        "speccoder {} · ",
1512        // The command the person typed, echoed.
1513        "speccoder {} --yes",
1514    ];
1515
1516    /// Every `speccoder …` command the catalog and every builder print
1517    /// (each string literal of the core, runtime, web and CLI sources,
1518    /// placeholders filled) is performed, opened as a view or run as one
1519    /// of speccoder's own commands — never only copied, and never words
1520    /// the parser could not read.
1521    #[test]
1522    fn no_speccoder_command_is_copy_only() {
1523        let ctx = Context {
1524            feature: Some("001-x"),
1525            task: Some("T001"),
1526        };
1527        let mut seen = 0;
1528        let mut errors = Vec::new();
1529        for e in speccoder_core::codes::CATALOG {
1530            for line in speccoder_core::codes::fixes(e, "run", Some("T001"), Some("001-x")) {
1531                if line.starts_with("speccoder ") && !copied(&line) {
1532                    let how = runnable_of(&line, ctx).how;
1533                    if how.copy_only() {
1534                        errors.push(format!("{}: {line} → {how:?}", e.name));
1535                    }
1536                    seen += 1;
1537                }
1538            }
1539        }
1540        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1541        let mut files = Vec::new();
1542        for dir in [
1543            "src",
1544            "../speccoder-core/src",
1545            "../speccoder-web/src",
1546            "../speccoder/src",
1547        ] {
1548            let mut stack = vec![root.join(dir)];
1549            while let Some(d) = stack.pop() {
1550                for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
1551                    let p = entry.path();
1552                    if p.is_dir() {
1553                        stack.push(p);
1554                    } else if p.extension().is_some_and(|x| x == "rs") {
1555                        files.push(p);
1556                    }
1557                }
1558            }
1559        }
1560        assert!(files.len() > 100, "{} files", files.len());
1561        for f in files {
1562            let src = std::fs::read_to_string(&f).unwrap_or_default();
1563            // A test's lines are its cases, not what speccoder prints.
1564            let src = src
1565                .split("#[cfg(test)]\nmod tests")
1566                .next()
1567                .unwrap_or_default();
1568            for (at, _) in src.match_indices("\"speccoder ") {
1569                // A quote within another literal is no literal.
1570                if src[..at].ends_with('\\') {
1571                    continue;
1572                }
1573                let mut line = String::new();
1574                let mut chars = src[at + 1..].chars();
1575                while let Some(c) = chars.next() {
1576                    match c {
1577                        '\\' => line.extend(chars.next()),
1578                        '"' => break,
1579                        c => line.push(c),
1580                    }
1581                }
1582                // A format string's arguments, filled.
1583                let mut filled = String::new();
1584                let mut rest = line.as_str();
1585                while let Some(open) = rest.find('{') {
1586                    filled.push_str(&rest[..open]);
1587                    let close = rest[open..].find('}').map_or(rest.len(), |c| open + c + 1);
1588                    // A suffix (`doctor{f}`: ` --feature F` or nothing).
1589                    let suffix = filled.ends_with(|c: char| c.is_ascii_alphanumeric());
1590                    filled.push_str(match &rest[open..close] {
1591                        _ if suffix => "",
1592                        "{task}" | "{t}" => "T001",
1593                        "{feature}" | "{f}" => "001-x",
1594                        "{command}" | "{verb}" => "run",
1595                        _ if filled.ends_with("--through ") => "tasks",
1596                        _ => "x",
1597                    });
1598                    rest = &rest[close.min(rest.len())..];
1599                }
1600                filled.push_str(rest);
1601                // A prefix compared against, no line.
1602                if line == "speccoder "
1603                    || copied(&filled)
1604                    || PROSE.iter().any(|p| line.starts_with(p))
1605                {
1606                    continue;
1607                }
1608                let how = runnable_of(&filled, ctx).how;
1609                if how.copy_only() {
1610                    errors.push(format!("{}: {filled} → {how:?}", f.display()));
1611                }
1612                seen += 1;
1613            }
1614        }
1615        assert!(errors.is_empty(), "\n{}", errors.join("\n"));
1616        assert!(seen > 100, "{seen} lines seen");
1617    }
1618
1619    #[test]
1620    fn blank_input_is_no_input() {
1621        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1622        let run = Runnable::Reject {
1623            task: "T001".to_owned(),
1624            call: "attempt:T001".to_owned(),
1625            digest: String::new(),
1626        };
1627        let r = perform_in(
1628            dir.path(),
1629            None,
1630            Dispatch {
1631                run: &run,
1632                input: Some("  "),
1633                via: Via::Web,
1634                options: RunOptions::default(),
1635            },
1636            None,
1637        );
1638        assert_eq!(
1639            (r.command(), r.status(), r.message()),
1640            ("job", CommandStatus::UsageError, "a reason is needed")
1641        );
1642    }
1643
1644    #[test]
1645    fn a_project_outside_git_still_gets_a_list() {
1646        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1647        let list = available(dir.path(), None, None);
1648        assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1649        assert!(primary(&list).is_none());
1650    }
1651}