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 fix =
41 speccoder_core::codes::fixes(entry, result.command(), task.as_deref(), feature);
42 result = result.with_code(entry.name).with_fix(fix);
43 }
44 }
45 result
46}
47
48fn by_code(code: &str) -> Option<Vec<speccoder_core::next::NextStep>> {
50 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
51 command: command.to_owned(),
52 why: why.to_owned(),
53 };
54 Some(match code {
55 "KEY-MISSING" => vec![step(
56 "speccoder doctor",
57 "check the rest of the setup once the key is set",
58 )],
59 "SETUP-INCOMPLETE" => vec![
60 step(
61 "speccoder fix --dry-run",
62 "what the fix commits, changing nothing",
63 ),
64 step("speccoder run", "run again once it is committed"),
65 ],
66 "CONFIG-INVALID" => vec![step(
67 "speccoder config",
68 "every key, its value, and what is wrong",
69 )],
70 "NOTHING-TO-HAND-OFF" => vec![step(
71 "speccoder run",
72 "tick tasks first: a pull request carries their evidence",
73 )],
74 "NO-CHECKS" => vec![step(
75 "speccoder config",
76 "every key: add a check under Checks",
77 )],
78 "CONFIG-EXISTS" => vec![step(
79 "speccoder config",
80 "every key and its value: change what you need",
81 )],
82 "CONFIG-NOT-TRACKED" => vec![step(
83 "speccoder config commit",
84 "speccoder reads committed configuration only",
85 )],
86 "GITHUB-AUTH" => vec![step(
87 "speccoder auth login --with-token",
88 "sign in to GitHub (the token goes to the keychain)",
89 )],
90 "GITHUB-FAILED" | "NO-REMOTE" => {
91 vec![step("speccoder doctor", "check the rest of the setup")]
92 }
93 _ => return None,
94 })
95}
96
97const fn outcome_code(outcome: &Outcome) -> Option<&'static str> {
99 Some(match outcome {
100 Outcome::NeedsApproval { .. } => "NEEDS-APPROVAL",
101 Outcome::Blocked { .. } => "BLOCKED",
102 Outcome::BudgetExhausted { .. } => "BUDGET-EXHAUSTED",
103 Outcome::VerificationFailed { .. } => "VERIFICATION-FAILED",
104 Outcome::SuspectedGaming { .. } => "SUSPECTED-GAMING",
105 Outcome::TestsOverStrict { .. } => "TESTS-OVER-STRICT",
106 Outcome::AcceptanceWeak { .. } => "ACCEPTANCE-WEAK",
107 Outcome::NotDemonstrated { .. } => "NOT-DEMONSTRATED",
108 Outcome::RunFailed { .. } => "RUN-FAILED",
109 Outcome::Interrupted { .. } => "INTERRUPTED",
110 Outcome::NothingReady => "TASK-NOT-READY",
111 Outcome::ChecklistsOpen => "CHECKLISTS-OPEN",
112 Outcome::ConfigError => "CONFIG",
113 Outcome::GitPrecondition => "GIT",
114 Outcome::ArchiveAwaiting { .. } => "ARCHIVE-AWAITING",
115 _ => return None,
116 })
117}
118
119fn outcome_task(outcome: &Outcome) -> Option<String> {
120 match outcome {
121 Outcome::NeedsApproval { task }
122 | Outcome::Blocked { task }
123 | Outcome::BudgetExhausted { task }
124 | Outcome::VerificationFailed { task }
125 | Outcome::SuspectedGaming { task }
126 | Outcome::TestsOverStrict { task }
127 | Outcome::AcceptanceWeak { task }
128 | Outcome::NotDemonstrated { task }
129 | Outcome::Interrupted { task }
130 | Outcome::TaskPassed { task, .. } => Some(task.clone()),
131 Outcome::RunFailed { task } => task.clone(),
132 _ => None,
133 }
134}
135
136fn regression_only(result: &CommandResult) -> bool {
138 use speccoder_core::verify::Verdict;
139 let is = |r: &RunReport| r.ticked && r.verdict == Some(Verdict::RegressionOnly);
140 result.status() == CommandStatus::Ok
141 && match result.data() {
142 Some(CommandData::Run(r)) => is(r),
143 Some(CommandData::Feature(f)) => f.attempts.iter().any(is),
144 _ => false,
145 }
146}
147
148#[must_use]
152pub fn in_place(result: CommandResult) -> CommandResult {
153 let steps = result
154 .next()
155 .iter()
156 .cloned()
157 .map(|mut s| {
158 for verb in ["run", "accept", "converge"] {
159 let head = format!("speccoder {verb}");
160 if (s.command == head || s.command.starts_with(&format!("{head} ")))
161 && !s.command.contains("--in-place")
162 {
163 s.command = format!("{head} --in-place{}", &s.command[head.len()..]);
164 }
165 }
166 s
167 })
168 .collect();
169 result.with_next(steps)
170}
171
172fn classify(result: &CommandResult) -> Option<Outcome> {
173 let status = result.status();
174 match (result.command(), status, result.data()) {
175 (_, _, Some(CommandData::InPlace(_))) => None,
177 (_, _, Some(CommandData::Checklists(_))) => Some(Outcome::ChecklistsOpen),
178 ("spec", CommandStatus::NeedsHuman, Some(CommandData::Archive(a)))
179 if a.stage == "awaiting_confirmation" =>
180 {
181 Some(Outcome::ArchiveAwaiting {
182 change: a.change.clone(),
183 })
184 }
185 ("run", _, Some(CommandData::Run(r))) => Some(of_run(status, r)),
186 ("run", _, Some(CommandData::Feature(f))) => match (status, f.attempts.last()) {
187 (CommandStatus::Ok, _) if f.stop.starts_with("feature done") => {
188 Some(Outcome::FeatureDone)
189 }
190 (CommandStatus::Ok, Some(last)) => Some(Outcome::TaskPassed {
191 task: last.task.clone(),
192 ready: 1,
193 }),
194 (CommandStatus::Ok, None) => Some(Outcome::FeatureDone),
195 (CommandStatus::TaskNotReady, _) => Some(Outcome::NothingReady),
196 (_, Some(last)) => Some(of_run(status, last)),
197 (_, None) => of_status(status),
198 },
199 ("report", CommandStatus::Ok, Some(CommandData::Report(r))) if !r.planning.is_empty() => {
200 Some(Outcome::Planning {
201 skills: r.planning.clone(),
202 })
203 }
204 ("report", CommandStatus::Ok, Some(CommandData::Report(r))) => Some(Outcome::ReportShown {
205 ready: r.totals.ready,
206 stale: r.totals.stale,
207 open_approvals: r
208 .tasks
209 .iter()
210 .filter(|t| {
211 t.last_attempt
212 .as_ref()
213 .is_some_and(|a| a.outcome == "needs_approval")
214 })
215 .count(),
216 }),
217 ("approvals", CommandStatus::Ok, Some(CommandData::Approvals(a))) => {
218 Some(Outcome::ApprovalsListed {
219 first: a.pending.first().map(|p| p.task.clone()),
220 })
221 }
222 ("approve" | "reject" | "accept" | "skip" | "nudge", CommandStatus::Ok, _) => {
223 Some(Outcome::Decided)
224 }
225 _ => of_status(status),
226 }
227}
228
229fn of_run(status: CommandStatus, r: &RunReport) -> Outcome {
231 let task = r.task.clone();
232 let suspected = r.monitor.as_ref().is_some_and(|m| m["suspected"] == true);
233 match (status, r.attempt_status.as_str()) {
234 (CommandStatus::Ok, _) => Outcome::TaskPassed { task, ready: 1 },
235 _ if suspected => Outcome::SuspectedGaming { task },
236 (_, "over_strict") => Outcome::TestsOverStrict { task },
237 (_, "acceptance_weak") => Outcome::AcceptanceWeak { task },
238 (_, "needs_approval") => Outcome::NeedsApproval { task },
239 (_, "blocked") => Outcome::Blocked { task },
240 (_, "budget_exhausted" | "incomplete" | "denied") => Outcome::BudgetExhausted { task },
241 (_, "interrupted") => Outcome::Interrupted { task },
242 (_, "not_demonstrated") => Outcome::NotDemonstrated { task },
243 (CommandStatus::VerificationFailed, _) => Outcome::VerificationFailed { task },
244 (CommandStatus::ConfigError, _) => Outcome::ConfigError,
245 (CommandStatus::GitPrecondition, _) => Outcome::GitPrecondition,
246 _ => Outcome::RunFailed { task: Some(task) },
247 }
248}
249
250fn of_status(status: CommandStatus) -> Option<Outcome> {
252 match status {
253 CommandStatus::ConfigError => Some(Outcome::ConfigError),
254 CommandStatus::GitPrecondition => Some(Outcome::GitPrecondition),
255 _ => None,
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn decisions_and_config_errors_get_their_steps() {
265 let decided = attach(CommandResult::ok("approve", "approved"), Some("001-x"));
266 assert_eq!(decided.next()[0].command, "speccoder run --feature 001-x");
267 let config = attach(
268 CommandResult::new("verify", CommandStatus::ConfigError, "x"),
269 None,
270 );
271 assert_eq!(config.next()[0].command, "speccoder init");
272 assert!(
273 attach(CommandResult::ok("inspect", "x"), None)
274 .next()
275 .is_empty()
276 );
277 }
278
279 #[test]
280 fn steps_after_an_in_place_run_stay_in_place() {
281 let decided = in_place(attach(
282 CommandResult::ok("approve", "approved"),
283 Some("001-x"),
284 ));
285 assert_eq!(
286 decided.next()[0].command,
287 "speccoder run --in-place --feature 001-x"
288 );
289 let refused = attach(
290 CommandResult::new("run", CommandStatus::GitPrecondition, "x")
291 .with_data(CommandData::InPlace(Box::new(
292 speccoder_core::InPlaceReport { paths: Vec::new() },
293 )))
294 .with_code("DIRTY-CHECKOUT")
295 .with_fix(vec!["git stash -u".to_owned()]),
296 None,
297 );
298 assert!(refused.next().is_empty(), "the fix lines say what to do");
299 }
300}