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