Skip to main content

speccoder_core/
actions.rs

1//! What a person can do now: one list, computed by the runtime and
2//! rendered by every interface — the dashboard's `n`, `c` and `:` today, a
3//! desktop app or another workbench later. Every action is a CLI command
4//! too, so nothing an interface offers is out of reach of a script.
5
6use serde::{Deserialize, Serialize};
7
8/// How an interface runs an action itself.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10#[serde(tag = "kind", rename_all = "snake_case")]
11pub enum Runnable {
12    /// A planning skill (`speccoder spec <name> <arguments>`).
13    Skill {
14        /// The skill.
15        name: String,
16        /// Its arguments (the input, when one is asked for).
17        arguments: String,
18    },
19    /// `speccoder run`, the feature or one task.
20    Run {
21        /// One task, or the feature's ready tasks.
22        task: Option<String>,
23        /// This run's agent (`--agent`): an ACP preset or `native`; `None`
24        /// is the configured executor.
25        #[serde(default, skip_serializing_if = "Option::is_none")]
26        agent: Option<String>,
27    },
28    /// `speccoder run --converge`: the ready tasks, then the assessment of
29    /// the code against the spec as it is now, appending what remains.
30    Converge,
31    /// Commit exactly these planning files.
32    Commit {
33        /// Repository-relative paths.
34        paths: Vec<String>,
35    },
36    /// Approve the task's waiting call: the one that was shown, refused
37    /// when another waits now.
38    Approve {
39        /// The task.
40        task: String,
41        /// The call's id ([`crate::PendingCall::id`]).
42        call: String,
43        /// The digest of the call that was shown ([`crate::PendingCall::digest`]).
44        digest: String,
45    },
46    /// Reject it (the input is the reason), bound the same way.
47    Reject {
48        /// The task.
49        task: String,
50        /// The call's id ([`crate::PendingCall::id`]).
51        call: String,
52        /// The digest of the call that was shown ([`crate::PendingCall::digest`]).
53        digest: String,
54    },
55    /// Tick the task on the person's word (the input is the reason).
56    Accept {
57        /// The task.
58        task: String,
59    },
60    /// Skip the task (the input is the reason).
61    Skip {
62        /// The task.
63        task: String,
64    },
65    /// Leave a note for the task's next attempts (the input is the note).
66    Nudge {
67        /// The task.
68        task: String,
69    },
70    /// I did it: tick a step the person took on their word (`attested`;
71    /// the input, when given, is the reason).
72    Done {
73        /// The task.
74        task: String,
75    },
76    /// Hint & retry: a note for the task (the input), then a new attempt.
77    Hint {
78        /// The task.
79        task: String,
80    },
81    /// Rule that a held attempt's tests are right (the input is the reason).
82    Rule {
83        /// The task.
84        task: String,
85    },
86    /// Record the person's review of requirements (the input is the
87    /// comment; required to request changes).
88    Review {
89        /// The requirement identifiers; empty: every one not yet approved.
90        ids: Vec<String>,
91        /// `approve`, `request` (changes), `clear`; `answer` (ids: the
92        /// question) and `edit` / `edit-approve` (ids: the requirement) take
93        /// the input as the words.
94        verdict: String,
95    },
96    /// Tell a GitHub issue it is being specified as the current feature.
97    IssueComment {
98        /// The issue's number.
99        issue: u64,
100        /// Also assign it to the person.
101        assign: bool,
102    },
103    /// Record a quick change and, when `run`, run it.
104    QuickChange {
105        /// The change, in the person's words.
106        request: String,
107        /// When it is done: its acceptance scenarios.
108        checks: Vec<String>,
109        /// Run it once recorded.
110        run: bool,
111    },
112    /// Commit the changes set aside before the task's run onto the
113    /// feature's branch, as the person's own.
114    KeepSetAside {
115        /// The task.
116        task: String,
117    },
118    /// Discard them.
119    DropSetAside {
120        /// The task.
121        task: String,
122    },
123    /// Commit the setup a run stops on (an uncommitted lockfile, build
124    /// output not ignored) in the checkout.
125    FixSetup,
126    /// Pick the current feature.
127    Feature,
128    /// Push the feature's branch and open (or update) its draft pull request.
129    PullRequest,
130    /// Record a bug in the person's words (`speccoder bug`) and, when
131    /// `run`, reproduce it.
132    BugFix {
133        /// What happens (the symptom).
134        symptom: String,
135        /// What should happen instead (one or more).
136        expected: Vec<String>,
137        /// What must keep working (zero or more).
138        unchanged: Vec<String>,
139        /// The GitHub issue it comes from.
140        issue: Option<u64>,
141        /// The person's own reproduction tests, one per expected statement
142        /// (`PATH::NAME`).
143        tests: Vec<String>,
144        /// Someone else's fix to judge: a patch file or `pr:N`.
145        candidate: Option<String>,
146        /// Continue a bug the format's own bug step created.
147        slug: Option<String>,
148        /// Reproduce it once recorded.
149        run: bool,
150    },
151    /// Confirm (or, with `reject`, reject: the input is the reason) a
152    /// bug's reproductions, bound to the digest shown.
153    BugConfirm {
154        /// `<unit>/<task>`.
155        task: String,
156        /// The digest the person saw.
157        digest: String,
158        /// Reject instead.
159        reject: bool,
160    },
161    /// Keep an already-satisfied bug's reproductions as a regression test:
162    /// committed, the task attested (the input is the reason).
163    BugKeep {
164        /// `<unit>/<task>`.
165        task: String,
166    },
167    /// Continue a bug: reproduce again, or run its confirmed fix.
168    BugRun {
169        /// A bug of the format's own bug structure, by slug.
170        slug: Option<String>,
171        /// Or `<unit>/<task>`.
172        task: Option<String>,
173    },
174    /// Always Allow: approve the waiting command once and add exactly its
175    /// argv to `[commands] allow` (or `[sandbox] network_allow`).
176    AllowAlways {
177        /// The task.
178        task: String,
179        /// The waiting call's id.
180        call: String,
181    },
182    /// Add a prefix to a prefix list, as the confirmation of its coverage
183    /// preview (refused when the preview changed or is incomplete).
184    WidenPolicy {
185        /// Which list.
186        key: PolicyList,
187        /// The prefix (two elements or more).
188        prefix: Vec<String>,
189        /// The digest of the preview the person saw.
190        digest: String,
191    },
192    /// Decide the calls waiting in a run's batch together, each once.
193    DecideBatch {
194        /// The run.
195        run: String,
196        /// Approve or reject (the input is the reason).
197        verdict: BatchVerdict,
198        /// The listed calls decided (a non-empty subset).
199        calls: Vec<String>,
200        /// The digest of the list the person saw.
201        digest: String,
202        /// Start the run's asking budget again.
203        resume_asking: bool,
204    },
205    /// Write, reject, ask again for, edit or settle a requirement's tests
206    /// at review (the input: the reason, the comment or the content).
207    OracleTests {
208        /// The requirement (`US1`).
209        req: String,
210        /// What to do.
211        op: crate::oracle::OracleOp,
212        /// The test (reject, again, edit).
213        path: Option<String>,
214        /// The ambiguous scenario (reading).
215        scenario: Option<String>,
216        /// The reading meant (reading).
217        reading: Option<crate::oracle::Reading>,
218    },
219    /// Approve every approvable decision line, freeze the written tests and
220    /// start the feature's run, asking or on autopilot.
221    ApproveAndRun {
222        /// How the run asks.
223        mode: crate::oracle::RunMode,
224        /// The grant ids (object-bound grants) an autopilot run holds.
225        grants: Vec<String>,
226        /// The sheet's `sha256`: what the person saw approved and frozen.
227        /// Required from every interface but a terminal command.
228        #[serde(default, skip_serializing_if = "Option::is_none")]
229        sheet: Option<String>,
230    },
231    /// Add, delete or keep a review comment (the input: its words).
232    Comment {
233        /// What to do.
234        op: CommentOp,
235        /// What a new comment is about.
236        anchor: Option<crate::oracle::CommentAnchor>,
237        /// The draft (delete, keep).
238        id: Option<String>,
239    },
240    /// Send the comments as one revision turn.
241    CommentsSend {
242        /// The drafts the person saw (empty: every draft in the tray); a
243        /// draft sent already refuses the send.
244        ids: Vec<String>,
245    },
246    /// Undo the last revision turn: the documents back, byte for byte.
247    RevisionUndo {
248        /// The turn the person saw.
249        turn: Option<String>,
250    },
251    /// Code-view line comments as one nudge for the task's next attempt.
252    CodeNudge {
253        /// The task.
254        task: String,
255        /// The comments.
256        comments: Vec<crate::oracle::CodeComment>,
257    },
258    /// Write a story's acceptance tests at a Setup or Foundational task
259    /// (`revoke`: take the decision back) — a decision recorded in the
260    /// state directory, never an edit of the spec.
261    AcceptanceAt {
262        /// The feature.
263        feature: String,
264        /// The story (`US1`).
265        story: String,
266        /// The task.
267        task: String,
268        /// Take the decision back.
269        revoke: bool,
270    },
271    /// Install the bundled `speccoder` Spec Kit preset through Spec Kit's
272    /// own `specify preset add` (`update`: the installed one, replaced).
273    PresetAdd {
274        /// Replace an installed older version.
275        update: bool,
276    },
277    /// Plan a new feature through the format's own skills — the creating
278    /// skill, then toward the task list, then the check — stopping where a
279    /// person decides (`speccoder spec <skill> --through <t>`).
280    PlanThrough {
281        /// A new description, or the continuation after a stop.
282        from: PlanFrom,
283        /// How far.
284        through: Through,
285    },
286    /// Make `target` a project: the plan the person saw, bound to its digest
287    /// (`speccoder new`).
288    NewProject {
289        /// The folder, as the plan names it.
290        target: String,
291        /// What the person chose.
292        choices: crate::new_project::NewChoices,
293        /// The digest of the plan on screen.
294        plan_digest: String,
295    },
296    /// Undo the project speccoder created at the dispatch's root
297    /// (`speccoder new --undo`): to the Trash, or a revert of its commit.
298    UndoNewProject {
299        /// speccoder's commit, as recorded.
300        commit: String,
301    },
302    /// Clone a repository of the person's with their git (their credentials).
303    CloneRepository {
304        /// `owner/repo`.
305        repo: String,
306        /// The destination folder.
307        target: String,
308    },
309    /// Copy the bundled sample into the state directory and replay its
310    /// recorded run strictly (`speccoder sample`).
311    OpenSample {
312        /// Replace an existing copy.
313        fresh: bool,
314    },
315    /// Run the sample's own checks, offline, in its copy (the sample's
316    /// end card, *Run the checks yourself*).
317    SampleChecks,
318}
319
320/// How far plan through goes.
321#[derive(
322    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
323)]
324#[serde(rename_all = "snake_case")]
325pub enum Through {
326    /// The spec only.
327    Spec,
328    /// The spec and the plan.
329    Plan,
330    /// The spec, the plan and the tasks.
331    Tasks,
332    /// Then the format's check of the planning files.
333    Analyze,
334}
335
336impl Through {
337    /// Every value, in order.
338    pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
339
340    /// Its name (`tasks`).
341    #[must_use]
342    pub const fn as_str(self) -> &'static str {
343        match self {
344            Self::Spec => "spec",
345            Self::Plan => "plan",
346            Self::Tasks => "tasks",
347            Self::Analyze => "analyze",
348        }
349    }
350
351    /// The value named `s`.
352    #[must_use]
353    pub fn parse(s: &str) -> Option<Self> {
354        Self::ALL.into_iter().find(|t| t.as_str() == s)
355    }
356}
357
358/// Where a new feature's description comes from. Later rungs add theirs
359/// (an Explore brief, a promoted quick change) when they are built.
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
361#[serde(tag = "kind", rename_all = "snake_case")]
362pub enum FeatureSource {
363    /// The person's own words.
364    Description,
365    /// A GitHub issue's description, verbatim.
366    Issue {
367        /// Its number.
368        number: u64,
369    },
370}
371
372/// What plan through starts from.
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
374#[serde(tag = "kind", rename_all = "snake_case")]
375pub enum PlanFrom {
376    /// A new unit from a description.
377    Create {
378        /// What it should do, verbatim.
379        description: String,
380        /// Where the description comes from.
381        source: FeatureSource,
382    },
383    /// The next skill after a stop, derived from the unit's files.
384    Continue,
385}
386
387/// What a comment action does.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
389#[serde(rename_all = "snake_case")]
390pub enum CommentOp {
391    /// A new draft.
392    Add,
393    /// Remove a draft.
394    Delete,
395    /// Keep an outdated draft.
396    Keep,
397}
398
399/// A prefix list in `speccoder.toml`.
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
401#[serde(rename_all = "snake_case")]
402pub enum PolicyList {
403    /// `[commands] allow_prefix`.
404    CommandsPrefix,
405    /// `[sandbox] network_allow_prefix`.
406    NetworkPrefix,
407}
408
409impl PolicyList {
410    /// Its name as serialized.
411    #[must_use]
412    pub const fn as_str(self) -> &'static str {
413        match self {
414            Self::CommandsPrefix => "commands_prefix",
415            Self::NetworkPrefix => "network_prefix",
416        }
417    }
418
419    /// The configuration key it is (`commands.allow_prefix`).
420    #[must_use]
421    pub const fn key(self) -> &'static str {
422        match self {
423            Self::CommandsPrefix => "commands.allow_prefix",
424            Self::NetworkPrefix => "sandbox.network_allow_prefix",
425        }
426    }
427
428    /// The list for a configuration key.
429    #[must_use]
430    pub fn of_key(key: &str) -> Option<Self> {
431        match key {
432            "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
433            "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
434            _ => None,
435        }
436    }
437}
438
439/// A batch decision.
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
441#[serde(rename_all = "snake_case")]
442pub enum BatchVerdict {
443    /// Each listed call once.
444    Approve,
445    /// Each listed call, with one reason.
446    Reject,
447}
448
449/// What kind of action it is: one per [`Runnable`] kind (a run split by
450/// whether it names a task), plus [`ActionKind::Command`] for an action an
451/// interface does not run itself. Exhaustive at any one version; a new
452/// kind arrives with its `Runnable` variant (the *adding a kind* contract
453/// of `speccoder_runtime::actions::perform`).
454#[derive(
455    Debug,
456    Clone,
457    Copy,
458    PartialEq,
459    Eq,
460    Hash,
461    PartialOrd,
462    Ord,
463    Serialize,
464    Deserialize,
465    schemars::JsonSchema,
466)]
467#[serde(rename_all = "snake_case")]
468pub enum ActionKind {
469    /// [`Runnable::Skill`].
470    Skill,
471    /// [`Runnable::Run`] with a task.
472    RunTask,
473    /// [`Runnable::Run`] without one: the feature.
474    RunFeature,
475    /// [`Runnable::Converge`].
476    Converge,
477    /// [`Runnable::Commit`].
478    Commit,
479    /// [`Runnable::Approve`].
480    Approve,
481    /// [`Runnable::Reject`].
482    Reject,
483    /// [`Runnable::Accept`].
484    Accept,
485    /// [`Runnable::Skip`].
486    Skip,
487    /// [`Runnable::Nudge`].
488    Nudge,
489    /// [`Runnable::Done`].
490    Done,
491    /// [`Runnable::Hint`].
492    Hint,
493    /// [`Runnable::Rule`].
494    Rule,
495    /// [`Runnable::Review`].
496    Review,
497    /// [`Runnable::IssueComment`].
498    IssueComment,
499    /// [`Runnable::QuickChange`].
500    QuickChange,
501    /// [`Runnable::KeepSetAside`].
502    KeepSetAside,
503    /// [`Runnable::DropSetAside`].
504    DropSetAside,
505    /// [`Runnable::FixSetup`].
506    FixSetup,
507    /// [`Runnable::Feature`].
508    Feature,
509    /// [`Runnable::PullRequest`].
510    PullRequest,
511    /// [`Runnable::BugFix`].
512    BugFix,
513    /// [`Runnable::BugConfirm`].
514    BugConfirm,
515    /// [`Runnable::BugRun`].
516    BugRun,
517    /// [`Runnable::BugKeep`].
518    BugKeep,
519    /// [`Runnable::AllowAlways`].
520    AllowAlways,
521    /// [`Runnable::WidenPolicy`].
522    WidenPolicy,
523    /// [`Runnable::DecideBatch`].
524    DecideBatch,
525    /// [`Runnable::OracleTests`].
526    OracleTests,
527    /// [`Runnable::ApproveAndRun`].
528    ApproveAndRun,
529    /// [`Runnable::Comment`].
530    Comment,
531    /// [`Runnable::CommentsSend`].
532    CommentsSend,
533    /// [`Runnable::RevisionUndo`].
534    RevisionUndo,
535    /// [`Runnable::CodeNudge`].
536    CodeNudge,
537    /// [`Runnable::AcceptanceAt`].
538    AcceptanceAt,
539    /// [`Runnable::PresetAdd`].
540    PresetAdd,
541    /// [`Runnable::PlanThrough`].
542    PlanThrough,
543    /// [`Runnable::NewProject`].
544    NewProject,
545    /// [`Runnable::UndoNewProject`].
546    UndoNewProject,
547    /// [`Runnable::CloneRepository`].
548    CloneRepository,
549    /// [`Runnable::OpenSample`].
550    OpenSample,
551    /// [`Runnable::SampleChecks`].
552    SampleChecks,
553    /// No runnable: the person runs the command.
554    Command,
555}
556
557impl ActionKind {
558    /// Every kind, in declaration order (a test holds it to the enum).
559    pub const ALL: &'static [Self] = &[
560        Self::Skill,
561        Self::RunTask,
562        Self::RunFeature,
563        Self::Converge,
564        Self::Commit,
565        Self::Approve,
566        Self::Reject,
567        Self::Accept,
568        Self::Skip,
569        Self::Nudge,
570        Self::Done,
571        Self::Hint,
572        Self::Rule,
573        Self::Review,
574        Self::IssueComment,
575        Self::QuickChange,
576        Self::KeepSetAside,
577        Self::DropSetAside,
578        Self::FixSetup,
579        Self::Feature,
580        Self::PullRequest,
581        Self::BugFix,
582        Self::BugConfirm,
583        Self::BugRun,
584        Self::BugKeep,
585        Self::AllowAlways,
586        Self::WidenPolicy,
587        Self::DecideBatch,
588        Self::OracleTests,
589        Self::ApproveAndRun,
590        Self::Comment,
591        Self::CommentsSend,
592        Self::RevisionUndo,
593        Self::CodeNudge,
594        Self::AcceptanceAt,
595        Self::PresetAdd,
596        Self::PlanThrough,
597        Self::NewProject,
598        Self::UndoNewProject,
599        Self::CloneRepository,
600        Self::OpenSample,
601        Self::SampleChecks,
602        Self::Command,
603    ];
604
605    /// Its name as serialized (`run_task`).
606    #[must_use]
607    pub const fn as_str(self) -> &'static str {
608        match self {
609            Self::Skill => "skill",
610            Self::RunTask => "run_task",
611            Self::RunFeature => "run_feature",
612            Self::Converge => "converge",
613            Self::Commit => "commit",
614            Self::Approve => "approve",
615            Self::Reject => "reject",
616            Self::Accept => "accept",
617            Self::Skip => "skip",
618            Self::Nudge => "nudge",
619            Self::Done => "done",
620            Self::Hint => "hint",
621            Self::Rule => "rule",
622            Self::Review => "review",
623            Self::IssueComment => "issue_comment",
624            Self::QuickChange => "quick_change",
625            Self::KeepSetAside => "keep_set_aside",
626            Self::DropSetAside => "drop_set_aside",
627            Self::FixSetup => "fix_setup",
628            Self::Feature => "feature",
629            Self::PullRequest => "pull_request",
630            Self::BugFix => "bug_fix",
631            Self::BugConfirm => "bug_confirm",
632            Self::BugRun => "bug_run",
633            Self::BugKeep => "bug_keep",
634            Self::AllowAlways => "allow_always",
635            Self::WidenPolicy => "widen_policy",
636            Self::DecideBatch => "decide_batch",
637            Self::OracleTests => "oracle_tests",
638            Self::ApproveAndRun => "approve_and_run",
639            Self::Comment => "comment",
640            Self::CommentsSend => "comments_send",
641            Self::RevisionUndo => "revision_undo",
642            Self::CodeNudge => "code_nudge",
643            Self::AcceptanceAt => "acceptance_at",
644            Self::PresetAdd => "preset_add",
645            Self::PlanThrough => "plan_through",
646            Self::NewProject => "new_project",
647            Self::UndoNewProject => "undo_new_project",
648            Self::CloneRepository => "clone_repository",
649            Self::OpenSample => "open_sample",
650            Self::SampleChecks => "sample_checks",
651            Self::Command => "command",
652        }
653    }
654
655    /// One runnable of this kind (`None` for [`ActionKind::Command`]): what
656    /// tests and documentation enumerate the kinds by.
657    #[must_use]
658    pub fn example(self) -> Option<Runnable> {
659        let task = || "T001".to_owned();
660        Some(match self {
661            Self::Skill => Runnable::Skill {
662                name: "plan".to_owned(),
663                arguments: String::new(),
664            },
665            Self::RunTask => Runnable::Run {
666                task: Some(task()),
667                agent: None,
668            },
669            Self::RunFeature => Runnable::Run {
670                task: None,
671                agent: None,
672            },
673            Self::Converge => Runnable::Converge,
674            Self::Commit => Runnable::Commit {
675                paths: vec!["plan.md".to_owned()],
676            },
677            Self::Approve => Runnable::Approve {
678                task: task(),
679                call: "0f3c".to_owned(),
680                digest: "d1".to_owned(),
681            },
682            Self::Reject => Runnable::Reject {
683                task: task(),
684                call: "0f3c".to_owned(),
685                digest: "d1".to_owned(),
686            },
687            Self::Accept => Runnable::Accept { task: task() },
688            Self::Skip => Runnable::Skip { task: task() },
689            Self::Nudge => Runnable::Nudge { task: task() },
690            Self::Done => Runnable::Done { task: task() },
691            Self::Hint => Runnable::Hint { task: task() },
692            Self::Rule => Runnable::Rule { task: task() },
693            Self::Review => Runnable::Review {
694                ids: vec!["FR-001".to_owned()],
695                verdict: "approve".to_owned(),
696            },
697            Self::IssueComment => Runnable::IssueComment {
698                issue: 42,
699                assign: false,
700            },
701            Self::QuickChange => Runnable::QuickChange {
702                request: "a change".to_owned(),
703                checks: Vec::new(),
704                run: false,
705            },
706            Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
707            Self::DropSetAside => Runnable::DropSetAside { task: task() },
708            Self::FixSetup => Runnable::FixSetup,
709            Self::Feature => Runnable::Feature,
710            Self::PullRequest => Runnable::PullRequest,
711            Self::BugFix => Runnable::BugFix {
712                symptom: "saving an empty title crashes".to_owned(),
713                expected: vec!["an empty title is refused".to_owned()],
714                unchanged: Vec::new(),
715                issue: None,
716                tests: Vec::new(),
717                candidate: None,
718                slug: None,
719                run: false,
720            },
721            Self::BugConfirm => Runnable::BugConfirm {
722                task: "007-bugfixes/T001".to_owned(),
723                digest: "sha256:0".to_owned(),
724                reject: false,
725            },
726            Self::BugRun => Runnable::BugRun {
727                slug: None,
728                task: Some("007-bugfixes/T001".to_owned()),
729            },
730            Self::BugKeep => Runnable::BugKeep {
731                task: "007-bugfixes/T001".to_owned(),
732            },
733            Self::AllowAlways => Runnable::AllowAlways {
734                task: task(),
735                call: "0f3c".to_owned(),
736            },
737            Self::WidenPolicy => Runnable::WidenPolicy {
738                key: PolicyList::CommandsPrefix,
739                prefix: vec!["npm".to_owned(), "run".to_owned()],
740                digest: "d1".to_owned(),
741            },
742            Self::DecideBatch => Runnable::DecideBatch {
743                run: "run-1-0a0b0c0d".to_owned(),
744                verdict: BatchVerdict::Approve,
745                calls: vec!["0f3c".to_owned()],
746                digest: "d1".to_owned(),
747                resume_asking: false,
748            },
749            Self::OracleTests => Runnable::OracleTests {
750                req: "US1".to_owned(),
751                op: crate::oracle::OracleOp::Write,
752                path: None,
753                scenario: None,
754                reading: None,
755            },
756            Self::ApproveAndRun => Runnable::ApproveAndRun {
757                mode: crate::oracle::RunMode::Ask,
758                grants: Vec::new(),
759                sheet: None,
760            },
761            Self::Comment => Runnable::Comment {
762                op: CommentOp::Delete,
763                anchor: None,
764                id: Some("c1".to_owned()),
765            },
766            Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
767            Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
768            Self::CodeNudge => Runnable::CodeNudge {
769                task: task(),
770                comments: vec![crate::oracle::CodeComment {
771                    path: "src/lib.rs".to_owned(),
772                    line: 1,
773                    quote: "pub fn ok() {}".to_owned(),
774                    text: "name it".to_owned(),
775                }],
776            },
777            Self::AcceptanceAt => Runnable::AcceptanceAt {
778                feature: "001-greet-function".to_owned(),
779                story: "US1".to_owned(),
780                task: "T003".to_owned(),
781                revoke: false,
782            },
783            Self::PresetAdd => Runnable::PresetAdd { update: false },
784            Self::PlanThrough => Runnable::PlanThrough {
785                from: PlanFrom::Create {
786                    description: "Users can export a report as CSV".to_owned(),
787                    source: FeatureSource::Description,
788                },
789                through: Through::Tasks,
790            },
791            Self::NewProject => Runnable::NewProject {
792                target: "/tmp/md2csv".to_owned(),
793                choices: crate::new_project::NewChoices {
794                    idea: "a CLI that turns Markdown tables into CSV".to_owned(),
795                    ..crate::new_project::NewChoices::default()
796                },
797                plan_digest: "sha256:0".to_owned(),
798            },
799            Self::UndoNewProject => Runnable::UndoNewProject {
800                commit: "1a2b3c4".to_owned(),
801            },
802            Self::CloneRepository => Runnable::CloneRepository {
803                repo: "octo/hello".to_owned(),
804                target: "/tmp/hello".to_owned(),
805            },
806            Self::OpenSample => Runnable::OpenSample { fresh: false },
807            Self::SampleChecks => Runnable::SampleChecks,
808            Self::Command => return None,
809        })
810    }
811}
812
813impl Runnable {
814    /// One runnable of every kind ([`ActionKind::example`]).
815    #[must_use]
816    pub fn examples() -> Vec<Self> {
817        ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
818    }
819
820    /// Its kind.
821    #[must_use]
822    pub const fn kind(&self) -> ActionKind {
823        match self {
824            Self::Skill { .. } => ActionKind::Skill,
825            Self::Run { task: Some(_), .. } => ActionKind::RunTask,
826            Self::Run { task: None, .. } => ActionKind::RunFeature,
827            Self::Converge => ActionKind::Converge,
828            Self::Commit { .. } => ActionKind::Commit,
829            Self::Approve { .. } => ActionKind::Approve,
830            Self::Reject { .. } => ActionKind::Reject,
831            Self::Accept { .. } => ActionKind::Accept,
832            Self::Skip { .. } => ActionKind::Skip,
833            Self::Nudge { .. } => ActionKind::Nudge,
834            Self::Done { .. } => ActionKind::Done,
835            Self::Hint { .. } => ActionKind::Hint,
836            Self::Rule { .. } => ActionKind::Rule,
837            Self::Review { .. } => ActionKind::Review,
838            Self::IssueComment { .. } => ActionKind::IssueComment,
839            Self::QuickChange { .. } => ActionKind::QuickChange,
840            Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
841            Self::DropSetAside { .. } => ActionKind::DropSetAside,
842            Self::FixSetup => ActionKind::FixSetup,
843            Self::Feature => ActionKind::Feature,
844            Self::PullRequest => ActionKind::PullRequest,
845            Self::BugFix { .. } => ActionKind::BugFix,
846            Self::BugConfirm { .. } => ActionKind::BugConfirm,
847            Self::BugRun { .. } => ActionKind::BugRun,
848            Self::BugKeep { .. } => ActionKind::BugKeep,
849            Self::AllowAlways { .. } => ActionKind::AllowAlways,
850            Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
851            Self::DecideBatch { .. } => ActionKind::DecideBatch,
852            Self::OracleTests { .. } => ActionKind::OracleTests,
853            Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
854            Self::Comment { .. } => ActionKind::Comment,
855            Self::CommentsSend { .. } => ActionKind::CommentsSend,
856            Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
857            Self::CodeNudge { .. } => ActionKind::CodeNudge,
858            Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
859            Self::PresetAdd { .. } => ActionKind::PresetAdd,
860            Self::PlanThrough { .. } => ActionKind::PlanThrough,
861            Self::NewProject { .. } => ActionKind::NewProject,
862            Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
863            Self::CloneRepository { .. } => ActionKind::CloneRepository,
864            Self::OpenSample { .. } => ActionKind::OpenSample,
865            Self::SampleChecks => ActionKind::SampleChecks,
866        }
867    }
868}
869
870/// What an action is, independent of its wording: its kind and what it
871/// is about (the task, the skill, the issue). An interface finds an action
872/// by this, never by its label.
873#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
874pub struct ActionId {
875    /// Its kind.
876    pub kind: ActionKind,
877    /// What it is about: a task's id, a skill's name, an issue's number,
878    /// a review's `verdict` (`verdict:first id`), a command's words.
879    pub subject: Option<String>,
880}
881
882/// The identity of the action that runs `run` (or, without one, stands
883/// for `command`): derived, so it cannot disagree with what runs.
884#[must_use]
885pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
886    let Some(run) = run else {
887        // The command's words: `speccoder attest verify` → `attest verify`.
888        let words: Vec<&str> = command
889            .split_whitespace()
890            .skip_while(|w| *w == "speccoder")
891            .take_while(|w| {
892                w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
893                    && w.chars()
894                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
895            })
896            .collect();
897        return ActionId {
898            kind: ActionKind::Command,
899            subject: (!words.is_empty()).then(|| words.join(" ")),
900        };
901    };
902    let subject = match run {
903        Runnable::Skill { name, .. } => Some(name.clone()),
904        Runnable::Run { task, .. } => task.clone(),
905        Runnable::Approve { task, .. }
906        | Runnable::Reject { task, .. }
907        | Runnable::Accept { task }
908        | Runnable::Skip { task }
909        | Runnable::Nudge { task }
910        | Runnable::Done { task }
911        | Runnable::Hint { task }
912        | Runnable::Rule { task }
913        | Runnable::KeepSetAside { task }
914        | Runnable::DropSetAside { task }
915        | Runnable::AllowAlways { task, .. }
916        | Runnable::CodeNudge { task, .. }
917        | Runnable::AcceptanceAt { task, .. } => Some(task.clone()),
918        Runnable::OracleTests { req, .. } => Some(req.clone()),
919        Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
920        Runnable::Comment { id, .. } => id.clone(),
921        Runnable::RevisionUndo { turn } => turn.clone(),
922        Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
923        Runnable::DecideBatch { run, .. } => Some(run.clone()),
924        Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
925        Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
926        Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
927        Runnable::BugFix { slug, issue, .. } => {
928            slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
929        }
930        Runnable::Review { ids, verdict } => Some(match ids.first() {
931            Some(id) => format!("{verdict}:{id}"),
932            None => verdict.clone(),
933        }),
934        Runnable::PlanThrough { from, through } => Some(match from {
935            PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
936            PlanFrom::Continue => format!("continue:{}", through.as_str()),
937        }),
938        Runnable::NewProject { target, .. } => Some(target.clone()),
939        Runnable::UndoNewProject { commit } => Some(commit.clone()),
940        Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
941        Runnable::Converge
942        | Runnable::OpenSample { .. }
943        | Runnable::SampleChecks
944        | Runnable::Commit { .. }
945        | Runnable::QuickChange { .. }
946        | Runnable::FixSetup
947        | Runnable::Feature
948        | Runnable::PullRequest
949        | Runnable::PresetAdd { .. }
950        | Runnable::CommentsSend { .. } => None,
951    };
952    ActionId {
953        kind: run.kind(),
954        subject,
955    }
956}
957
958/// The part a planning skill plays in its format's planning, from the
959/// format's adapter: what an interface finds "the create step" or "the
960/// analysis step" by, without naming a format's skill.
961#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
962#[serde(rename_all = "snake_case")]
963pub enum SkillRole {
964    /// It creates a unit (Spec Kit's `specify`).
965    Create,
966    /// It leads toward the task list (`plan`, `tasks`).
967    TowardTasks,
968    /// It revises a written spec (`clarify`).
969    Revise,
970    /// It checks the planning files for consistency (`analyze`).
971    Check,
972    /// Anything else.
973    Other,
974}
975
976/// The interface a person acted through, as a write records it (`via`):
977/// one vocabulary for every record. Records written before it named
978/// `cli`, `tui` or `dashboard` (read as [`Via::Terminal`]) and
979/// `editor <name>` (read as [`Via::Acp`], the name a separate client).
980#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
981#[serde(rename_all = "snake_case")]
982pub enum Via {
983    /// A terminal: the line CLI, its prompts, the dashboard.
984    #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
985    Terminal,
986    /// The web app, in a browser tab or the desktop window.
987    Web,
988    /// The desktop window, once it can tell itself from a tab.
989    Window,
990    /// An editor over the Agent Client Protocol.
991    Acp,
992    /// A phone.
993    Phone,
994    /// Continuous integration.
995    Ci,
996    /// A client over the Model Context Protocol.
997    Mcp,
998}
999
1000impl Via {
1001    /// Its recorded value (`terminal`).
1002    #[must_use]
1003    pub const fn as_str(self) -> &'static str {
1004        match self {
1005            Self::Terminal => "terminal",
1006            Self::Web => "web",
1007            Self::Window => "window",
1008            Self::Acp => "acp",
1009            Self::Phone => "phone",
1010            Self::Ci => "ci",
1011            Self::Mcp => "mcp",
1012        }
1013    }
1014
1015    /// A recorded value, read back with the client it names (`editor Zed`
1016    /// → `acp`, `Zed`); `None` for a value no interface ever recorded.
1017    #[must_use]
1018    pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
1019        if let Some(client) = recorded.strip_prefix("editor ") {
1020            return Some((Self::Acp, Some(client.to_owned())));
1021        }
1022        let via = match recorded {
1023            "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
1024            "web" => Self::Web,
1025            "window" => Self::Window,
1026            "acp" | "editor" => Self::Acp,
1027            "phone" => Self::Phone,
1028            "ci" => Self::Ci,
1029            "mcp" => Self::Mcp,
1030            _ => return None,
1031        };
1032        Some((via, None))
1033    }
1034}
1035
1036impl std::fmt::Display for Via {
1037    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1038        f.write_str(self.as_str())
1039    }
1040}
1041
1042/// One thing a person can do now.
1043#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1044pub struct Action {
1045    /// `next`, `plan`, `work`, `task`, `feature` or `review`.
1046    pub group: String,
1047    /// What it does, in a few words.
1048    pub label: String,
1049    /// Why, or what follows (shown beside it).
1050    pub why: String,
1051    /// The equivalent command; `"…"` marks the input.
1052    pub command: String,
1053    /// How an interface runs it; `None`: see `step`.
1054    pub run: Option<Runnable>,
1055    /// How an interface takes it when it does not run it (`run` is `None`):
1056    /// a view it opens, the person's own command, a line for their shell.
1057    pub step: Option<crate::step::How>,
1058    /// Why it cannot run now; `None`: it can.
1059    pub blocked: Option<String>,
1060    /// What to ask for before it runs (`Describe the feature`).
1061    pub input: Option<String>,
1062    /// It leaves the machine (a push, a pull request) or cannot be undone
1063    /// (a discard): what an interface says in a confirmation before it runs it.
1064    pub confirm: Option<String>,
1065    /// What it is, independent of its wording ([`identity`]).
1066    pub id: ActionId,
1067    /// A planning skill's part in its format's planning; `None` for
1068    /// everything else.
1069    pub role: Option<SkillRole>,
1070}
1071
1072impl Action {
1073    /// An action that stands for `command`, which the person runs.
1074    #[must_use]
1075    pub fn new(
1076        group: &str,
1077        label: impl Into<String>,
1078        why: impl Into<String>,
1079        command: impl Into<String>,
1080    ) -> Self {
1081        let command = command.into();
1082        Self {
1083            group: group.to_owned(),
1084            label: label.into(),
1085            why: why.into(),
1086            id: identity(None, &command),
1087            step: Some(crate::step::step_of(&command, crate::step::Context::default()).how),
1088            command,
1089            run: None,
1090            blocked: None,
1091            input: None,
1092            confirm: None,
1093            role: None,
1094        }
1095    }
1096
1097    /// Run by an interface as `run`; its identity follows.
1098    #[must_use]
1099    pub fn with_run(mut self, run: Runnable) -> Self {
1100        self.id = identity(Some(&run), &self.command);
1101        self.run = Some(run);
1102        self.step = None;
1103        self
1104    }
1105
1106    /// A planning skill playing `role`.
1107    #[must_use]
1108    pub const fn with_role(mut self, role: SkillRole) -> Self {
1109        self.role = Some(role);
1110        self
1111    }
1112
1113    /// It can be run from an interface, now.
1114    #[must_use]
1115    pub const fn runnable(&self) -> bool {
1116        self.run.is_some() && self.blocked.is_none()
1117    }
1118}
1119
1120/// The primary action: the first runnable one of the `next` group.
1121#[must_use]
1122pub fn primary(actions: &[Action]) -> Option<&Action> {
1123    actions.iter().find(|a| a.group == "next" && a.runnable())
1124}
1125
1126/// Where a step of the journey is.
1127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1128#[serde(rename_all = "snake_case")]
1129pub enum StepState {
1130    /// Done.
1131    Done,
1132    /// The one to take now.
1133    Now,
1134    /// Later.
1135    Todo,
1136    /// Passed without its file: a task list written without a spec or a
1137    /// plan. Not a step left to take.
1138    Skipped,
1139}
1140
1141/// One step of a feature's journey: `spec`, `plan`, `tasks`, `commit`,
1142/// `run`, `review`.
1143#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1144pub struct JourneyStep {
1145    /// Its name.
1146    pub name: String,
1147    /// Where it is.
1148    pub state: StepState,
1149}
1150
1151/// Facts the journey is derived from.
1152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1153pub struct JourneyFacts {
1154    /// `spec.md` exists.
1155    pub spec: bool,
1156    /// `plan.md` exists.
1157    pub plan: bool,
1158    /// The task list exists.
1159    pub tasks: bool,
1160    /// Planning files are not committed.
1161    pub uncommitted: bool,
1162    /// Tasks in the list.
1163    pub total: usize,
1164    /// Of them ticked or skipped.
1165    pub finished: usize,
1166}
1167
1168/// The journey from a spec to a reviewed branch, and where the feature is
1169/// on it: everything before the first open step is done (or skipped, when a
1170/// later planning file exists without it), that one is now.
1171#[must_use]
1172pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1173    let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1174    let done = [
1175        ("spec", f.spec),
1176        ("plan", f.plan),
1177        // A task list without a task is not written yet.
1178        ("tasks", f.tasks && f.total > 0),
1179        ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1180        ("run", f.total > 0 && f.finished == f.total),
1181        ("review", false),
1182    ];
1183    let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1184    let now = done
1185        .iter()
1186        .enumerate()
1187        .position(|(i, (_, d))| !d && !passed(i))
1188        .unwrap_or(done.len());
1189    done.iter()
1190        .enumerate()
1191        .map(|(i, (name, d))| JourneyStep {
1192            name: (*name).to_owned(),
1193            state: if *d && i < now {
1194                StepState::Done
1195            } else if passed(i) && i < now {
1196                StepState::Skipped
1197            } else if i == now {
1198                StepState::Now
1199            } else {
1200                StepState::Todo
1201            },
1202        })
1203        .collect()
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209
1210    #[test]
1211    fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1212        // The schema lists every variant the compiler knows.
1213        let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1214            .unwrap_or_else(|e| panic!("{e}"));
1215        let mut named: Vec<String> = schema["oneOf"]
1216            .as_array()
1217            .into_iter()
1218            .flatten()
1219            .filter_map(|v| v["const"].as_str().map(str::to_owned))
1220            .collect();
1221        named.sort();
1222        let mut listed: Vec<String> = ActionKind::ALL
1223            .iter()
1224            .map(|k| k.as_str().to_owned())
1225            .collect();
1226        listed.sort();
1227        assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1228        for k in ActionKind::ALL {
1229            assert_eq!(
1230                serde_json::to_value(k).unwrap_or_default(),
1231                k.as_str(),
1232                "{k:?}"
1233            );
1234            if let Some(run) = k.example() {
1235                assert_eq!(run.kind(), *k);
1236                assert_eq!(identity(Some(&run), "").kind, *k);
1237            }
1238        }
1239        assert_eq!(
1240            Runnable::examples().len(),
1241            ActionKind::ALL.len() - 1,
1242            "every kind but `command` has a runnable"
1243        );
1244    }
1245
1246    #[test]
1247    fn identity_is_what_runs_never_how_it_is_worded() {
1248        let a = Action::new(
1249            "task",
1250            "accept T004",
1251            "tick it",
1252            "speccoder task accept T004",
1253        )
1254        .with_run(Runnable::Accept {
1255            task: "T004".to_owned(),
1256        });
1257        let mut b = a.clone();
1258        b.label = "x tick it anyway".to_owned();
1259        b.why = "x".to_owned();
1260        assert_eq!(a.id, b.id);
1261        assert_eq!(
1262            a.id,
1263            ActionId {
1264                kind: ActionKind::Accept,
1265                subject: Some("T004".to_owned())
1266            }
1267        );
1268        let id = |c: &str| identity(None, c).subject;
1269        assert_eq!(
1270            id("speccoder attest verify").as_deref(),
1271            Some("attest verify")
1272        );
1273        assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1274        assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1275        assert_eq!(
1276            id("speccoder spec specify \"…\"").as_deref(),
1277            Some("spec specify")
1278        );
1279        let review = |ids: &[&str], v: &str| {
1280            identity(
1281                Some(&Runnable::Review {
1282                    ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1283                    verdict: v.to_owned(),
1284                }),
1285                "",
1286            )
1287            .subject
1288        };
1289        assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1290        assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1291    }
1292
1293    #[test]
1294    fn origins_read_back_from_every_value_ever_recorded() {
1295        for (old, via, client) in [
1296            ("cli", Via::Terminal, None),
1297            ("tui", Via::Terminal, None),
1298            ("dashboard", Via::Terminal, None),
1299            ("terminal", Via::Terminal, None),
1300            ("web", Via::Web, None),
1301            ("editor Zed", Via::Acp, Some("Zed")),
1302        ] {
1303            assert_eq!(
1304                Via::read(old),
1305                Some((via, client.map(str::to_owned))),
1306                "{old}"
1307            );
1308        }
1309        let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1310        assert_eq!(parsed, Via::Terminal);
1311        assert_eq!(Via::Acp.as_str(), "acp");
1312        assert_eq!(Via::read("nobody"), None);
1313    }
1314
1315    fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1316        journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1317    }
1318
1319    #[test]
1320    fn the_journey_marks_what_is_done_and_what_is_now() {
1321        use StepState::{Done, Now, Todo};
1322        let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1323        let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1324        assert_eq!(
1325            states(JourneyFacts::default()),
1326            at([Now, Todo, Todo, Todo, Todo, Todo])
1327        );
1328        let planned = JourneyFacts {
1329            spec: true,
1330            plan: true,
1331            ..JourneyFacts::default()
1332        };
1333        assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1334        let written = JourneyFacts {
1335            tasks: true,
1336            uncommitted: true,
1337            total: 3,
1338            ..planned
1339        };
1340        assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1341        let running = JourneyFacts {
1342            uncommitted: false,
1343            finished: 1,
1344            ..written
1345        };
1346        assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1347        let finished = JourneyFacts {
1348            finished: 3,
1349            ..running
1350        };
1351        assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1352        // A task list with no task: the tasks are still to write, never run.
1353        let empty = JourneyFacts {
1354            tasks: true,
1355            ..planned
1356        };
1357        assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1358    }
1359
1360    #[test]
1361    fn a_task_list_without_a_spec_skips_to_the_run() {
1362        use StepState::{Now, Skipped, Todo};
1363        let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1364        let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1365        let only_tasks = JourneyFacts {
1366            tasks: true,
1367            total: 4,
1368            finished: 1,
1369            ..JourneyFacts::default()
1370        };
1371        assert_eq!(
1372            states(only_tasks),
1373            at([
1374                Skipped,
1375                Skipped,
1376                StepState::Done,
1377                StepState::Done,
1378                Now,
1379                Todo
1380            ])
1381        );
1382        // A plan without a spec: the spec is skipped, the tasks are next.
1383        let planned = JourneyFacts {
1384            plan: true,
1385            ..JourneyFacts::default()
1386        };
1387        assert_eq!(
1388            states(planned),
1389            at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1390        );
1391    }
1392}