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    },
24    /// `speccoder run --converge`: the ready tasks, then the assessment of
25    /// the code against the spec as it is now, appending what remains.
26    Converge,
27    /// Commit exactly these planning files.
28    Commit {
29        /// Repository-relative paths.
30        paths: Vec<String>,
31    },
32    /// Approve the task's waiting call.
33    Approve {
34        /// The task.
35        task: String,
36    },
37    /// Reject it (the input is the reason).
38    Reject {
39        /// The task.
40        task: String,
41    },
42    /// Tick the task on the person's word (the input is the reason).
43    Accept {
44        /// The task.
45        task: String,
46    },
47    /// Skip the task (the input is the reason).
48    Skip {
49        /// The task.
50        task: String,
51    },
52    /// Leave a note for the task's next attempts (the input is the note).
53    Nudge {
54        /// The task.
55        task: String,
56    },
57    /// Rule that a held attempt's tests are right (the input is the reason).
58    Rule {
59        /// The task.
60        task: String,
61    },
62    /// Record the person's review of requirements (the input is the
63    /// comment; required to request changes).
64    Review {
65        /// The requirement identifiers; empty: every one not yet approved.
66        ids: Vec<String>,
67        /// `approve`, `request` (changes), `clear`; `answer` (ids: the
68        /// question) and `edit` / `edit-approve` (ids: the requirement) take
69        /// the input as the words.
70        verdict: String,
71    },
72    /// Tell a GitHub issue it is being specified as the current feature.
73    IssueComment {
74        /// The issue's number.
75        issue: u64,
76        /// Also assign it to the person.
77        assign: bool,
78    },
79    /// Record a quick change and, when `run`, run it.
80    QuickChange {
81        /// The change, in the person's words.
82        request: String,
83        /// When it is done: its acceptance scenarios.
84        checks: Vec<String>,
85        /// Run it once recorded.
86        run: bool,
87    },
88    /// Pick the current feature.
89    Feature,
90    /// Push the feature's branch and open (or update) its draft pull request.
91    PullRequest,
92}
93
94/// One thing a person can do now.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
96pub struct Action {
97    /// `next`, `plan`, `work`, `task`, `feature` or `review`.
98    pub group: String,
99    /// What it does, in a few words.
100    pub label: String,
101    /// Why, or what follows (shown beside it).
102    pub why: String,
103    /// The equivalent command; `"…"` marks the input.
104    pub command: String,
105    /// How an interface runs it; `None`: run the command yourself (it leaves
106    /// the machine, or it is not something an interface hosts).
107    pub run: Option<Runnable>,
108    /// Why it cannot run now; `None`: it can.
109    pub blocked: Option<String>,
110    /// What to ask for before it runs (`Describe the feature`).
111    pub input: Option<String>,
112    /// It leaves the machine (a push, a pull request): what an interface
113    /// says in a confirmation before it runs it.
114    pub confirm: Option<String>,
115}
116
117impl Action {
118    /// It can be run from an interface, now.
119    #[must_use]
120    pub const fn runnable(&self) -> bool {
121        self.run.is_some() && self.blocked.is_none()
122    }
123}
124
125/// The primary action: the first runnable one of the `next` group.
126#[must_use]
127pub fn primary(actions: &[Action]) -> Option<&Action> {
128    actions.iter().find(|a| a.group == "next" && a.runnable())
129}
130
131/// Where a step of the journey is.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
133#[serde(rename_all = "snake_case")]
134pub enum StepState {
135    /// Done.
136    Done,
137    /// The one to take now.
138    Now,
139    /// Later.
140    Todo,
141    /// Passed without its file: a task list written without a spec or a
142    /// plan. Not a step left to take.
143    Skipped,
144}
145
146/// One step of a feature's journey: `spec`, `plan`, `tasks`, `commit`,
147/// `run`, `review`.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
149pub struct JourneyStep {
150    /// Its name.
151    pub name: String,
152    /// Where it is.
153    pub state: StepState,
154}
155
156/// Facts the journey is derived from.
157#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
158pub struct JourneyFacts {
159    /// `spec.md` exists.
160    pub spec: bool,
161    /// `plan.md` exists.
162    pub plan: bool,
163    /// The task list exists.
164    pub tasks: bool,
165    /// Planning files are not committed.
166    pub uncommitted: bool,
167    /// Tasks in the list.
168    pub total: usize,
169    /// Of them ticked or skipped.
170    pub finished: usize,
171}
172
173/// The journey from a spec to a reviewed branch, and where the feature is
174/// on it: everything before the first open step is done (or skipped, when a
175/// later planning file exists without it), that one is now.
176#[must_use]
177pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
178    let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
179    let done = [
180        ("spec", f.spec),
181        ("plan", f.plan),
182        ("tasks", f.tasks),
183        ("commit", f.tasks && !f.uncommitted),
184        ("run", f.total > 0 && f.finished == f.total),
185        ("review", false),
186    ];
187    let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
188    let now = done
189        .iter()
190        .enumerate()
191        .position(|(i, (_, d))| !d && !passed(i))
192        .unwrap_or(done.len());
193    done.iter()
194        .enumerate()
195        .map(|(i, (name, d))| JourneyStep {
196            name: (*name).to_owned(),
197            state: if *d && i < now {
198                StepState::Done
199            } else if passed(i) && i < now {
200                StepState::Skipped
201            } else if i == now {
202                StepState::Now
203            } else {
204                StepState::Todo
205            },
206        })
207        .collect()
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
215        journey(f).into_iter().map(|s| (s.name, s.state)).collect()
216    }
217
218    #[test]
219    fn the_journey_marks_what_is_done_and_what_is_now() {
220        use StepState::{Done, Now, Todo};
221        let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
222        let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
223        assert_eq!(
224            states(JourneyFacts::default()),
225            at([Now, Todo, Todo, Todo, Todo, Todo])
226        );
227        let planned = JourneyFacts {
228            spec: true,
229            plan: true,
230            ..JourneyFacts::default()
231        };
232        assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
233        let written = JourneyFacts {
234            tasks: true,
235            uncommitted: true,
236            total: 3,
237            ..planned
238        };
239        assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
240        let running = JourneyFacts {
241            uncommitted: false,
242            finished: 1,
243            ..written
244        };
245        assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
246        let finished = JourneyFacts {
247            finished: 3,
248            ..running
249        };
250        assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
251    }
252
253    #[test]
254    fn a_task_list_without_a_spec_skips_to_the_run() {
255        use StepState::{Now, Skipped, Todo};
256        let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
257        let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
258        let only_tasks = JourneyFacts {
259            tasks: true,
260            total: 4,
261            finished: 1,
262            ..JourneyFacts::default()
263        };
264        assert_eq!(
265            states(only_tasks),
266            at([
267                Skipped,
268                Skipped,
269                StepState::Done,
270                StepState::Done,
271                Now,
272                Todo
273            ])
274        );
275        // A plan without a spec: the spec is skipped, the tasks are next.
276        let planned = JourneyFacts {
277            plan: true,
278            ..JourneyFacts::default()
279        };
280        assert_eq!(
281            states(planned),
282            at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
283        );
284    }
285}