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