1use speccoder_core::next::{Outcome, next};
6use speccoder_core::{CommandData, CommandResult, CommandStatus, RunReport};
7
8#[must_use]
13pub fn attach(result: CommandResult, feature: Option<&str>) -> CommandResult {
14 if result.next().is_empty()
17 && let Some(steps) = result.code().and_then(|c| by_code(c.name))
18 {
19 return result.with_next(steps);
20 }
21 let outcome = classify(&result);
22 let mut result = match &outcome {
24 Some(o) if result.next().is_empty() => {
25 let steps = next(o, feature);
26 result.with_next(steps)
27 }
28 _ => result,
29 };
30 if !result.has_explicit_code() {
31 let task = outcome.as_ref().and_then(outcome_task);
32 let named = outcome
33 .as_ref()
34 .and_then(outcome_code)
35 .or_else(|| regression_only(&result).then_some("REGRESSION-ONLY"))
36 .and_then(speccoder_core::codes::lookup)
37 .filter(|e| e.status == result.status())
38 .or_else(|| speccoder_core::codes::default_for(result.status()));
39 if let Some(entry) = named {
40 let mut fix =
41 speccoder_core::codes::fixes(entry, result.command(), task.as_deref(), feature);
42 if entry.name == "REGRESSION-ONLY"
43 && let Some(why) = regression_fix(&result, feature)
44 {
45 fix = why;
46 }
47 result = result.with_code(entry.name).with_fix(fix);
48 }
49 }
50 result
51}
52
53fn by_code(code: &str) -> Option<Vec<speccoder_core::next::NextStep>> {
55 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
56 command: command.to_owned(),
57 why: why.to_owned(),
58 };
59 Some(match code {
60 "KEY-MISSING" => vec![step(
61 "speccoder doctor",
62 "check the rest of the setup once the key is set",
63 )],
64 "SETUP-INCOMPLETE" => vec![
65 step(
66 "speccoder fix --dry-run",
67 "what the fix commits, changing nothing",
68 ),
69 step("speccoder run", "run again once it is committed"),
70 ],
71 "CONFIG-INVALID" => vec![step(
72 "speccoder config",
73 "every key, its value, and what is wrong",
74 )],
75 "NOTHING-TO-HAND-OFF" => vec![step(
76 "speccoder run",
77 "tick tasks first: a pull request carries their evidence",
78 )],
79 "NO-CHECKS" => vec![step(
80 "speccoder config",
81 "every key: add a check under Checks",
82 )],
83 "CONFIG-EXISTS" => vec![step(
84 "speccoder config",
85 "every key and its value: change what you need",
86 )],
87 "CONFIG-NOT-TRACKED" => vec![step(
88 "speccoder config commit",
89 "speccoder reads committed configuration only",
90 )],
91 "GITHUB-AUTH" => vec![step(
92 "speccoder auth login --with-token",
93 "sign in to GitHub (the token goes to the keychain)",
94 )],
95 "GITHUB-FAILED" | "NO-REMOTE" => {
96 vec![step("speccoder doctor", "check the rest of the setup")]
97 }
98 "NO-PROJECT" | "NO-REPOSITORY" => vec![
100 step(
101 "speccoder new \"<what you are building>\"",
102 "start a project here: the stack's and the spec format's own tools, then one commit",
103 ),
104 step(
105 "speccoder sample",
106 "a recorded run, replayed — no model, no key, no network",
107 ),
108 ],
109 _ => return None,
110 })
111}
112
113const fn outcome_code(outcome: &Outcome) -> Option<&'static str> {
115 Some(match outcome {
116 Outcome::NeedsApproval { .. } => "NEEDS-APPROVAL",
117 Outcome::Blocked { .. } => "BLOCKED",
118 Outcome::BudgetExhausted { .. } => "BUDGET-EXHAUSTED",
119 Outcome::Incomplete { .. } => "ATTEMPT-INCOMPLETE",
120 Outcome::VerificationFailed { .. } => "VERIFICATION-FAILED",
121 Outcome::SuspectedGaming { .. } => "SUSPECTED-GAMING",
122 Outcome::TestsOverStrict { .. } => "TESTS-OVER-STRICT",
123 Outcome::AcceptanceWeak { .. } => "ACCEPTANCE-WEAK",
124 Outcome::NotDemonstrated { .. } => "NOT-DEMONSTRATED",
125 Outcome::RunFailed { .. } => "RUN-FAILED",
126 Outcome::Interrupted { .. } => "INTERRUPTED",
127 Outcome::NothingReady => "TASK-NOT-READY",
128 Outcome::ChecklistsOpen => "CHECKLISTS-OPEN",
129 Outcome::ConfigError => "CONFIG",
130 Outcome::GitPrecondition => "GIT",
131 Outcome::ArchiveAwaiting { .. } => "ARCHIVE-AWAITING",
132 _ => return None,
133 })
134}
135
136fn outcome_task(outcome: &Outcome) -> Option<String> {
137 match outcome {
138 Outcome::NeedsApproval { task }
139 | Outcome::Blocked { task }
140 | Outcome::BudgetExhausted { task }
141 | Outcome::Incomplete { task }
142 | Outcome::VerificationFailed { task }
143 | Outcome::SuspectedGaming { task }
144 | Outcome::TestsOverStrict { task }
145 | Outcome::AcceptanceWeak { task }
146 | Outcome::NotDemonstrated { task }
147 | Outcome::Interrupted { task }
148 | Outcome::TaskPassed { task, .. } => Some(task.clone()),
149 Outcome::RunFailed { task } => task.clone(),
150 _ => None,
151 }
152}
153
154fn regression_fix(result: &CommandResult, feature: Option<&str>) -> Option<Vec<String>> {
156 use speccoder_core::regression::RegressionReason as R;
157 let reason = match result.data() {
158 Some(CommandData::Run(r)) => r.regression.clone(),
159 Some(CommandData::Feature(f)) => f.attempts.iter().rev().find_map(|a| a.regression.clone()),
160 _ => None,
161 }?
162 .reason;
163 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
164 let checks = format!("speccoder doctor{f}");
165 let converge = format!("speccoder converge{f}");
166 Some(match reason {
167 R::AcceptanceOff => vec!["→ set [acceptance] generate = true in speccoder.toml".to_owned()],
168 R::NoScenarios { story } => vec![
169 format!("→ add an acceptance scenario to {story}, then converge"),
170 converge,
171 ],
172 R::AlreadyPassing | R::NoStory => vec![
173 format!("{checks} (the plan checks: behaviour planned before its story)"),
174 converge,
175 ],
176 R::AuthorWroteNone
177 | R::AuthorFailed { .. }
178 | R::NotRed { .. }
179 | R::TestsNotCollected
180 | R::Unrecorded => vec![checks, converge],
181 R::PlanningWork | R::StoryVerified | R::InProgress => Vec::new(),
183 R::RegressionUnchecked => vec!["set `junit` on the test check in speccoder.toml, so a regression is told apart from the story's red tests".to_string()],
184 })
185}
186
187fn regression_only(result: &CommandResult) -> bool {
189 use speccoder_core::verify::Verdict;
190 let is = |r: &RunReport| r.ticked && r.verdict == Some(Verdict::RegressionOnly);
191 result.status() == CommandStatus::Ok
192 && match result.data() {
193 Some(CommandData::Run(r)) => is(r),
194 Some(CommandData::Feature(f)) => f.attempts.iter().any(is),
195 _ => false,
196 }
197}
198
199#[must_use]
203pub fn in_place(result: CommandResult) -> CommandResult {
204 let steps = result
205 .next()
206 .iter()
207 .cloned()
208 .map(|mut s| {
209 for verb in ["run", "accept", "converge"] {
210 let head = format!("speccoder {verb}");
211 if (s.command == head || s.command.starts_with(&format!("{head} ")))
212 && !s.command.contains("--in-place")
213 {
214 s.command = format!("{head} --in-place{}", &s.command[head.len()..]);
215 }
216 }
217 s
218 })
219 .collect();
220 result.with_next(steps)
221}
222
223fn classify(result: &CommandResult) -> Option<Outcome> {
224 let status = result.status();
225 match (result.command(), status, result.data()) {
226 (_, _, Some(CommandData::InPlace(_))) => None,
228 (_, _, Some(CommandData::Checklists(_))) => Some(Outcome::ChecklistsOpen),
229 ("spec", CommandStatus::NeedsHuman, Some(CommandData::Archive(a)))
230 if a.stage == "awaiting_confirmation" =>
231 {
232 Some(Outcome::ArchiveAwaiting {
233 change: a.change.clone(),
234 })
235 }
236 ("run", _, Some(CommandData::Run(r))) => Some(of_run(status, r)),
237 ("run", _, Some(CommandData::Feature(f))) => match (status, f.attempts.last()) {
238 (CommandStatus::Ok, _) if f.stop.starts_with("feature done") => {
239 Some(Outcome::FeatureDone)
240 }
241 (CommandStatus::Ok, Some(last)) => Some(Outcome::TaskPassed {
242 task: last.task.clone(),
243 ready: 1,
244 }),
245 (CommandStatus::Ok, None) => Some(Outcome::FeatureDone),
246 (CommandStatus::TaskNotReady, _) => Some(Outcome::NothingReady),
247 (_, Some(last)) => Some(of_run(status, last)),
248 (_, None) => of_status(status),
249 },
250 ("report", CommandStatus::Ok, Some(CommandData::Report(r))) if !r.planning.is_empty() => {
251 Some(Outcome::Planning {
252 skills: r.planning.clone(),
253 })
254 }
255 ("report", CommandStatus::Ok, Some(CommandData::Report(r))) => Some(Outcome::ReportShown {
256 ready: r.totals.ready,
257 stale: r.totals.stale,
258 open_approvals: r
259 .tasks
260 .iter()
261 .filter(|t| {
262 t.last_attempt
263 .as_ref()
264 .is_some_and(|a| a.outcome == "needs_approval")
265 })
266 .count(),
267 }),
268 ("approvals", CommandStatus::Ok, Some(CommandData::Approvals(a))) => {
269 Some(Outcome::ApprovalsListed {
270 first: a.pending.first().map(|p| p.task.clone()),
271 })
272 }
273 ("approve" | "reject" | "accept" | "skip" | "nudge", CommandStatus::Ok, _) => {
274 Some(Outcome::Decided)
275 }
276 _ => of_status(status),
277 }
278}
279
280fn of_run(status: CommandStatus, r: &RunReport) -> Outcome {
282 let task = r.task.clone();
283 let suspected = r.monitor.as_ref().is_some_and(|m| m["suspected"] == true);
284 match (status, r.attempt_status.as_str()) {
285 (CommandStatus::Ok, _) => Outcome::TaskPassed { task, ready: 1 },
286 _ if suspected => Outcome::SuspectedGaming { task },
287 (_, "over_strict") => Outcome::TestsOverStrict { task },
288 (_, "acceptance_weak") => Outcome::AcceptanceWeak { task },
289 (_, "needs_approval") => Outcome::NeedsApproval { task },
290 (_, "blocked") => Outcome::Blocked { task },
291 (_, "incomplete") => Outcome::Incomplete { task },
292 (_, "budget_exhausted" | "denied") => Outcome::BudgetExhausted { task },
293 (_, "interrupted") => Outcome::Interrupted { task },
294 (_, "not_demonstrated") => Outcome::NotDemonstrated { task },
295 (CommandStatus::VerificationFailed, _) => Outcome::VerificationFailed { task },
296 (CommandStatus::ConfigError, _) => Outcome::ConfigError,
297 (CommandStatus::GitPrecondition, _) => Outcome::GitPrecondition,
298 _ => Outcome::RunFailed { task: Some(task) },
299 }
300}
301
302fn of_status(status: CommandStatus) -> Option<Outcome> {
304 match status {
305 CommandStatus::ConfigError => Some(Outcome::ConfigError),
306 CommandStatus::GitPrecondition => Some(Outcome::GitPrecondition),
307 _ => None,
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn decisions_and_config_errors_get_their_steps() {
317 let decided = attach(CommandResult::ok("approve", "approved"), Some("001-x"));
318 assert_eq!(decided.next()[0].command, "speccoder run --feature 001-x");
319 let config = attach(
320 CommandResult::new("verify", CommandStatus::ConfigError, "x"),
321 None,
322 );
323 assert_eq!(config.next()[0].command, "speccoder init");
324 assert!(
325 attach(CommandResult::ok("inspect", "x"), None)
326 .next()
327 .is_empty()
328 );
329 }
330
331 #[test]
332 fn steps_after_an_in_place_run_stay_in_place() {
333 let decided = in_place(attach(
334 CommandResult::ok("approve", "approved"),
335 Some("001-x"),
336 ));
337 assert_eq!(
338 decided.next()[0].command,
339 "speccoder run --in-place --feature 001-x"
340 );
341 let refused = attach(
342 CommandResult::new("run", CommandStatus::GitPrecondition, "x")
343 .with_data(CommandData::InPlace(Box::new(
344 speccoder_core::InPlaceReport { paths: Vec::new() },
345 )))
346 .with_code("DIRTY-CHECKOUT")
347 .with_fix(vec!["git stash -u".to_owned()]),
348 None,
349 );
350 assert!(refused.next().is_empty(), "the fix lines say what to do");
351 }
352}