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            result = result.with_code(entry.name).with_fix(fix);
48        }
49    }
50    // A mid-story task whose acceptance tests it could not meet: judging
51    // them at the story's checkpoint instead comes first.
52    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
75/// The task, its story's checkpoint and the scenarios its tests cited,
76/// when the task can be deferred: its attempt ended incomplete, failed or
77/// blocked with acceptance tests red, and the test author chose its
78/// scenarios (no links, not the checkpoint) in a story with a later code task.
79fn 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
118/// Next steps for a failure whose code names it precisely.
119fn 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        // Outside a project: start one, or see what speccoder does first.
164        "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
178/// The catalog code an outcome stands for.
179const 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
219/// The fix for why a tick was regression-only (the last such tick's reason).
220fn 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        // Design documents, or a person's decision: nothing to do here.
247        R::PlanningWork | R::StoryVerified | R::DeferredToCheckpoint { .. } => Vec::new(),
248    })
249}
250
251/// An `ok` run that ticked a task on the regression checks alone.
252fn 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/// The steps of a result from an in-place run, in place: `speccoder run`,
264/// `accept` and `converge` gain `--in-place` (a worktree-mode run would not
265/// see git-ignored specs, and is refused while the session holds them).
266#[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        // An in-place refusal or stop names its own fix.
291        (_, _, 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
344/// One task run's outcome.
345fn 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
366/// Steps that depend only on how a command failed.
367fn 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}