Skip to main content

speccoder_runtime/
next.rs

1//! What to do next, from a command's result: its status and payload
2//! classified into an [`Outcome`], whose steps every renderer shows — the
3//! human `Next:` block, `data.next`, the TUI footer.
4
5use speccoder_core::next::{Outcome, next};
6use speccoder_core::{CommandData, CommandResult, CommandStatus, RunReport};
7
8/// `result` with its next steps and its code attached; `feature` is the
9/// `--feature` the command was given, carried to speccoder's own commands.
10/// A code the result already names is kept; otherwise the outcome's, or
11/// the status's default — every non-ok result leaves with one.
12#[must_use]
13pub fn attach(result: CommandResult, feature: Option<&str>) -> CommandResult {
14    // A code that says exactly what is wrong decides what comes next; the
15    // status alone ("a config error") would send a missing key to `init`.
16    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    // Steps already attached (an in-place run's) are kept.
23    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            // The stop's own lines (a ruling it offers) first, then the
48            // catalog's not already there.
49            // A line the same but for naming the feature is the same line.
50            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
66/// Next steps for a failure whose code names it precisely.
67fn 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        // Outside a project: start one, or see what speccoder does first.
112        "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
126/// The catalog code an outcome stands for.
127const 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
169/// The fix for why a tick was regression-only (the last such tick's reason).
170fn 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        // Design documents, or a person's decision: nothing to do here.
197        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
202/// An `ok` run that ticked a task on the regression checks alone.
203fn 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/// The steps of a result from an in-place run, in place: `speccoder run`,
215/// `accept` and `converge` gain `--in-place` (a worktree-mode run would not
216/// see git-ignored specs, and is refused while the session holds them).
217#[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        // An in-place refusal or stop names its own fix.
242        (_, _, 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
296/// One task run's outcome.
297fn 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
324/// Steps that depend only on how a command failed.
325fn 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    /// A run that stopped incomplete: its own ruling line first, the
368    /// code's lines after it, each once; attaching again changes nothing,
369    /// and steps already attached are kept.
370    #[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        // One nudge, though the stop's names the feature and the catalog's not.
386        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}