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