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