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}