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 if let Some((task, checkpoint, cited)) = deferrable(&result, outcome.as_ref()) {
53 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
54 let command = format!("speccoder task defer {task}{f}");
55 let why = if cited.is_empty() {
56 format!("its tests need later tasks of the story? judge its scenarios at {checkpoint}")
57 } else {
58 format!(
59 "its tests need later tasks of the story? judge {} at {checkpoint}",
60 cited.join(", ")
61 )
62 };
63 let mut steps = vec![speccoder_core::next::NextStep {
64 command: command.clone(),
65 why,
66 }];
67 steps.extend(result.next().iter().cloned());
68 let mut fix = vec![command];
69 fix.extend(result.fix());
70 result = result.with_next(steps).with_fix(fix);
71 }
72 result
73}
74
75fn deferrable(
80 result: &CommandResult,
81 outcome: Option<&Outcome>,
82) -> Option<(String, String, Vec<String>)> {
83 if !matches!(
84 outcome,
85 Some(
86 Outcome::Incomplete { .. }
87 | Outcome::VerificationFailed { .. }
88 | Outcome::Blocked { .. }
89 )
90 ) {
91 return None;
92 }
93 let Some(CommandData::Run(r)) = result.data() else {
94 return None;
95 };
96 let Some(speccoder_core::story::Share::Authored {
97 checkpoint: Some(checkpoint),
98 ..
99 }) = &r.share
100 else {
101 return None;
102 };
103 let acceptance = r.acceptance.as_ref()?;
104 if acceptance["red"] != true {
105 return None;
106 }
107 let mut cited: Vec<String> = Vec::new();
108 for t in acceptance["tests"].as_array().into_iter().flatten() {
109 for s in t["scenarios"].as_array().into_iter().flatten() {
110 if let Some(s) = s.as_str().filter(|s| !cited.iter().any(|c| c == s)) {
111 cited.push(s.to_owned());
112 }
113 }
114 }
115 Some((r.task.clone(), checkpoint.clone(), cited))
116}
117
118fn by_code(code: &str) -> Option<Vec<speccoder_core::next::NextStep>> {
120 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
121 command: command.to_owned(),
122 why: why.to_owned(),
123 };
124 Some(match code {
125 "KEY-MISSING" => vec![step(
126 "speccoder doctor",
127 "check the rest of the setup once the key is set",
128 )],
129 "SETUP-INCOMPLETE" => vec![
130 step(
131 "speccoder fix --dry-run",
132 "what the fix commits, changing nothing",
133 ),
134 step("speccoder run", "run again once it is committed"),
135 ],
136 "CONFIG-INVALID" => vec![step(
137 "speccoder config",
138 "every key, its value, and what is wrong",
139 )],
140 "NOTHING-TO-HAND-OFF" => vec![step(
141 "speccoder run",
142 "tick tasks first: a pull request carries their evidence",
143 )],
144 "NO-CHECKS" => vec![step(
145 "speccoder config",
146 "every key: add a check under Checks",
147 )],
148 "CONFIG-EXISTS" => vec![step(
149 "speccoder config",
150 "every key and its value: change what you need",
151 )],
152 "CONFIG-NOT-TRACKED" => vec![step(
153 "speccoder config commit",
154 "speccoder reads committed configuration only",
155 )],
156 "GITHUB-AUTH" => vec![step(
157 "speccoder auth login --with-token",
158 "sign in to GitHub (the token goes to the keychain)",
159 )],
160 "GITHUB-FAILED" | "NO-REMOTE" => {
161 vec![step("speccoder doctor", "check the rest of the setup")]
162 }
163 "NO-PROJECT" | "NO-REPOSITORY" => vec![
165 step(
166 "speccoder new \"<what you are building>\"",
167 "start a project here: the stack's and the spec format's own tools, then one commit",
168 ),
169 step(
170 "speccoder sample",
171 "a recorded run, replayed — no model, no key, no network",
172 ),
173 ],
174 _ => return None,
175 })
176}
177
178const fn outcome_code(outcome: &Outcome) -> Option<&'static str> {
180 Some(match outcome {
181 Outcome::NeedsApproval { .. } => "NEEDS-APPROVAL",
182 Outcome::Blocked { .. } => "BLOCKED",
183 Outcome::BudgetExhausted { .. } => "BUDGET-EXHAUSTED",
184 Outcome::Incomplete { .. } => "ATTEMPT-INCOMPLETE",
185 Outcome::VerificationFailed { .. } => "VERIFICATION-FAILED",
186 Outcome::SuspectedGaming { .. } => "SUSPECTED-GAMING",
187 Outcome::TestsOverStrict { .. } => "TESTS-OVER-STRICT",
188 Outcome::AcceptanceWeak { .. } => "ACCEPTANCE-WEAK",
189 Outcome::NotDemonstrated { .. } => "NOT-DEMONSTRATED",
190 Outcome::RunFailed { .. } => "RUN-FAILED",
191 Outcome::Interrupted { .. } => "INTERRUPTED",
192 Outcome::NothingReady => "TASK-NOT-READY",
193 Outcome::ChecklistsOpen => "CHECKLISTS-OPEN",
194 Outcome::ConfigError => "CONFIG",
195 Outcome::GitPrecondition => "GIT",
196 Outcome::ArchiveAwaiting { .. } => "ARCHIVE-AWAITING",
197 _ => return None,
198 })
199}
200
201fn outcome_task(outcome: &Outcome) -> Option<String> {
202 match outcome {
203 Outcome::NeedsApproval { task }
204 | Outcome::Blocked { task }
205 | Outcome::BudgetExhausted { task }
206 | Outcome::Incomplete { task }
207 | Outcome::VerificationFailed { task }
208 | Outcome::SuspectedGaming { task }
209 | Outcome::TestsOverStrict { task }
210 | Outcome::AcceptanceWeak { task }
211 | Outcome::NotDemonstrated { task }
212 | Outcome::Interrupted { task }
213 | Outcome::TaskPassed { task, .. } => Some(task.clone()),
214 Outcome::RunFailed { task } => task.clone(),
215 _ => None,
216 }
217}
218
219fn regression_fix(result: &CommandResult, feature: Option<&str>) -> Option<Vec<String>> {
221 use speccoder_core::regression::RegressionReason as R;
222 let reason = match result.data() {
223 Some(CommandData::Run(r)) => r.regression.clone(),
224 Some(CommandData::Feature(f)) => f.attempts.iter().rev().find_map(|a| a.regression.clone()),
225 _ => None,
226 }?
227 .reason;
228 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
229 let checks = format!("speccoder doctor{f}");
230 let converge = format!("speccoder converge{f}");
231 Some(match reason {
232 R::AcceptanceOff => vec!["→ set [acceptance] generate = true in speccoder.toml".to_owned()],
233 R::NoScenarios { story } => vec![
234 format!("→ add an acceptance scenario to {story}, then converge"),
235 converge,
236 ],
237 R::AlreadyPassing { .. } | R::NoStory => vec![
238 format!("{checks} (the plan checks: behaviour planned before its story)"),
239 converge,
240 ],
241 R::AuthorWroteNone
242 | R::AuthorFailed { .. }
243 | R::NotRed { .. }
244 | R::TestsNotCollected
245 | R::Unrecorded => vec![checks, converge],
246 R::PlanningWork | R::StoryVerified | R::DeferredToCheckpoint { .. } => Vec::new(),
248 })
249}
250
251fn regression_only(result: &CommandResult) -> bool {
253 use speccoder_core::verify::Verdict;
254 let is = |r: &RunReport| r.ticked && r.verdict == Some(Verdict::RegressionOnly);
255 result.status() == CommandStatus::Ok
256 && match result.data() {
257 Some(CommandData::Run(r)) => is(r),
258 Some(CommandData::Feature(f)) => f.attempts.iter().any(is),
259 _ => false,
260 }
261}
262
263#[must_use]
267pub fn in_place(result: CommandResult) -> CommandResult {
268 let steps = result
269 .next()
270 .iter()
271 .cloned()
272 .map(|mut s| {
273 for verb in ["run", "accept", "converge"] {
274 let head = format!("speccoder {verb}");
275 if (s.command == head || s.command.starts_with(&format!("{head} ")))
276 && !s.command.contains("--in-place")
277 {
278 s.command = format!("{head} --in-place{}", &s.command[head.len()..]);
279 }
280 }
281 s
282 })
283 .collect();
284 result.with_next(steps)
285}
286
287fn classify(result: &CommandResult) -> Option<Outcome> {
288 let status = result.status();
289 match (result.command(), status, result.data()) {
290 (_, _, Some(CommandData::InPlace(_))) => None,
292 (_, _, Some(CommandData::Checklists(_))) => Some(Outcome::ChecklistsOpen),
293 ("spec", CommandStatus::NeedsHuman, Some(CommandData::Archive(a)))
294 if a.stage == "awaiting_confirmation" =>
295 {
296 Some(Outcome::ArchiveAwaiting {
297 change: a.change.clone(),
298 })
299 }
300 ("run", _, Some(CommandData::Run(r))) => Some(of_run(status, r)),
301 ("run", _, Some(CommandData::Feature(f))) => match (status, f.attempts.last()) {
302 (CommandStatus::Ok, _) if f.stop.starts_with("feature done") => {
303 Some(Outcome::FeatureDone)
304 }
305 (CommandStatus::Ok, Some(last)) => Some(Outcome::TaskPassed {
306 task: last.task.clone(),
307 ready: 1,
308 }),
309 (CommandStatus::Ok, None) => Some(Outcome::FeatureDone),
310 (CommandStatus::TaskNotReady, _) => Some(Outcome::NothingReady),
311 (_, Some(last)) => Some(of_run(status, last)),
312 (_, None) => of_status(status),
313 },
314 ("report", CommandStatus::Ok, Some(CommandData::Report(r))) if !r.planning.is_empty() => {
315 Some(Outcome::Planning {
316 skills: r.planning.clone(),
317 })
318 }
319 ("report", CommandStatus::Ok, Some(CommandData::Report(r))) => Some(Outcome::ReportShown {
320 ready: r.totals.ready,
321 stale: r.totals.stale,
322 open_approvals: r
323 .tasks
324 .iter()
325 .filter(|t| {
326 t.last_attempt
327 .as_ref()
328 .is_some_and(|a| a.outcome == "needs_approval")
329 })
330 .count(),
331 }),
332 ("approvals", CommandStatus::Ok, Some(CommandData::Approvals(a))) => {
333 Some(Outcome::ApprovalsListed {
334 first: a.pending.first().map(|p| p.task.clone()),
335 })
336 }
337 ("approve" | "reject" | "accept" | "skip" | "nudge", CommandStatus::Ok, _) => {
338 Some(Outcome::Decided)
339 }
340 _ => of_status(status),
341 }
342}
343
344fn of_run(status: CommandStatus, r: &RunReport) -> Outcome {
346 let task = r.task.clone();
347 let suspected = r.monitor.as_ref().is_some_and(|m| m["suspected"] == true);
348 match (status, r.attempt_status.as_str()) {
349 (CommandStatus::Ok, _) => Outcome::TaskPassed { task, ready: 1 },
350 _ if suspected => Outcome::SuspectedGaming { task },
351 (_, "over_strict") => Outcome::TestsOverStrict { task },
352 (_, "acceptance_weak") => Outcome::AcceptanceWeak { task },
353 (_, "needs_approval") => Outcome::NeedsApproval { task },
354 (_, "blocked") => Outcome::Blocked { task },
355 (_, "incomplete") => Outcome::Incomplete { task },
356 (_, "budget_exhausted" | "denied") => Outcome::BudgetExhausted { task },
357 (_, "interrupted") => Outcome::Interrupted { task },
358 (_, "not_demonstrated") => Outcome::NotDemonstrated { task },
359 (CommandStatus::VerificationFailed, _) => Outcome::VerificationFailed { task },
360 (CommandStatus::ConfigError, _) => Outcome::ConfigError,
361 (CommandStatus::GitPrecondition, _) => Outcome::GitPrecondition,
362 _ => Outcome::RunFailed { task: Some(task) },
363 }
364}
365
366fn of_status(status: CommandStatus) -> Option<Outcome> {
368 match status {
369 CommandStatus::ConfigError => Some(Outcome::ConfigError),
370 CommandStatus::GitPrecondition => Some(Outcome::GitPrecondition),
371 _ => None,
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn decisions_and_config_errors_get_their_steps() {
381 let decided = attach(CommandResult::ok("approve", "approved"), Some("001-x"));
382 assert_eq!(decided.next()[0].command, "speccoder run --feature 001-x");
383 let config = attach(
384 CommandResult::new("verify", CommandStatus::ConfigError, "x"),
385 None,
386 );
387 assert_eq!(config.next()[0].command, "speccoder init");
388 assert!(
389 attach(CommandResult::ok("inspect", "x"), None)
390 .next()
391 .is_empty()
392 );
393 }
394
395 #[test]
396 fn steps_after_an_in_place_run_stay_in_place() {
397 let decided = in_place(attach(
398 CommandResult::ok("approve", "approved"),
399 Some("001-x"),
400 ));
401 assert_eq!(
402 decided.next()[0].command,
403 "speccoder run --in-place --feature 001-x"
404 );
405 let refused = attach(
406 CommandResult::new("run", CommandStatus::GitPrecondition, "x")
407 .with_data(CommandData::InPlace(Box::new(
408 speccoder_core::InPlaceReport { paths: Vec::new() },
409 )))
410 .with_code("DIRTY-CHECKOUT")
411 .with_fix(vec!["git stash -u".to_owned()]),
412 None,
413 );
414 assert!(refused.next().is_empty(), "the fix lines say what to do");
415 }
416}