use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepStatus {
Pending,
Active,
Done,
Failed,
}
impl StepStatus {
fn glyph(self) -> char {
match self {
StepStatus::Pending => ' ',
StepStatus::Active => '>',
StepStatus::Done => 'x',
StepStatus::Failed => '!',
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanStep {
pub description: String,
pub status: StepStatus,
}
impl PlanStep {
pub fn new(description: impl Into<String>) -> Self {
Self {
description: description.into(),
status: StepStatus::Pending,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan {
pub goal: String,
pub steps: Vec<PlanStep>,
}
impl Plan {
pub fn new(
goal: impl Into<String>,
steps: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
goal: goal.into(),
steps: steps.into_iter().map(PlanStep::new).collect(),
}
}
pub fn current(&self) -> Option<usize> {
self.steps
.iter()
.position(|s| matches!(s.status, StepStatus::Pending | StepStatus::Active))
}
pub fn is_complete(&self) -> bool {
self.current().is_none()
}
pub fn all_done(&self) -> bool {
!self.steps.is_empty() && self.steps.iter().all(|s| s.status == StepStatus::Done)
}
pub fn set_status(&mut self, idx: usize, status: StepStatus) -> bool {
match self.steps.get_mut(idx) {
Some(step) => {
step.status = status;
true
}
None => false,
}
}
pub fn render(&self) -> String {
let mut out = String::with_capacity(self.goal.len() + self.steps.len() * 32);
let _ = writeln!(out, "Goal: {}", self.goal);
for (i, step) in self.steps.iter().enumerate() {
let _ = writeln!(
out,
"{}. [{}] {}",
i + 1,
step.status.glyph(),
step.description
);
}
if out.ends_with('\n') {
out.pop();
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepOutcome {
Succeeded,
Stalled,
Diverged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplanAction {
Advance,
Retry,
Replan,
Done,
GiveUp,
}
#[derive(Debug, Clone, Copy)]
pub struct ReplanBudget {
pub max_step_retries: u32,
pub max_replans: u32,
}
impl Default for ReplanBudget {
fn default() -> Self {
Self {
max_step_retries: 2,
max_replans: 3,
}
}
}
pub fn replan_decision(
budget: &ReplanBudget,
outcome: StepOutcome,
step_attempts: u32,
replans_used: u32,
has_current: bool,
) -> ReplanAction {
match outcome {
StepOutcome::Succeeded => {
if has_current {
ReplanAction::Advance
} else {
ReplanAction::Done
}
}
StepOutcome::Stalled if step_attempts < budget.max_step_retries => ReplanAction::Retry,
StepOutcome::Stalled | StepOutcome::Diverged => {
if replans_used < budget.max_replans {
ReplanAction::Replan
} else {
ReplanAction::GiveUp
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn current_points_at_first_unfinished_step() {
let mut p = Plan::new("book a flight", ["search", "select", "pay"]);
assert_eq!(p.current(), Some(0));
assert!(p.set_status(0, StepStatus::Done));
assert_eq!(p.current(), Some(1), "advances past the Done step");
assert!(p.set_status(1, StepStatus::Active));
assert_eq!(p.current(), Some(1), "an Active step is still 'current'");
}
#[test]
fn is_complete_and_all_done_distinguish_failure() {
let mut p = Plan::new("task", ["a", "b"]);
assert!(!p.is_complete());
assert!(!p.all_done());
p.set_status(0, StepStatus::Done);
p.set_status(1, StepStatus::Failed);
assert!(p.is_complete(), "all steps terminal → complete");
assert!(!p.all_done(), "a Failed step means not all_done");
}
#[test]
fn all_done_is_false_for_empty_plan() {
let p = Plan::new("nothing", Vec::<String>::new());
assert!(!p.all_done(), "an empty plan has not accomplished anything");
assert!(p.is_complete(), "but it has no pending work");
}
#[test]
fn set_status_out_of_range_is_false_not_panic() {
let mut p = Plan::new("task", ["only"]);
assert!(!p.set_status(9, StepStatus::Done));
assert_eq!(p.steps[0].status, StepStatus::Pending, "unchanged");
}
#[test]
fn render_is_deterministic_and_shows_status_glyphs() {
let mut p = Plan::new("buy milk", ["open store", "add to cart", "checkout"]);
p.set_status(0, StepStatus::Done);
p.set_status(1, StepStatus::Active);
let r = p.render();
assert_eq!(
r,
"Goal: buy milk\n1. [x] open store\n2. [>] add to cart\n3. [ ] checkout"
);
assert_eq!(p.render(), r);
}
fn bud() -> ReplanBudget {
ReplanBudget {
max_step_retries: 2,
max_replans: 3,
}
}
#[test]
fn success_with_steps_remaining_advances() {
assert_eq!(
replan_decision(&bud(), StepOutcome::Succeeded, 0, 0, true),
ReplanAction::Advance
);
}
#[test]
fn success_with_no_steps_remaining_is_done() {
assert_eq!(
replan_decision(&bud(), StepOutcome::Succeeded, 0, 0, false),
ReplanAction::Done
);
}
#[test]
fn stall_retries_until_ceiling_then_replans() {
assert_eq!(
replan_decision(&bud(), StepOutcome::Stalled, 0, 0, true),
ReplanAction::Retry
);
assert_eq!(
replan_decision(&bud(), StepOutcome::Stalled, 1, 0, true),
ReplanAction::Retry
);
assert_eq!(
replan_decision(&bud(), StepOutcome::Stalled, 2, 0, true),
ReplanAction::Replan
);
}
#[test]
fn diverged_replans_immediately_without_retrying() {
assert_eq!(
replan_decision(&bud(), StepOutcome::Diverged, 0, 0, true),
ReplanAction::Replan
);
}
#[test]
fn replan_budget_exhaustion_gives_up() {
assert_eq!(
replan_decision(&bud(), StepOutcome::Diverged, 0, 3, true),
ReplanAction::GiveUp
);
assert_eq!(
replan_decision(&bud(), StepOutcome::Stalled, 5, 3, true),
ReplanAction::GiveUp
);
}
#[test]
fn zero_retry_budget_escalates_on_first_stall() {
let b = ReplanBudget {
max_step_retries: 0,
max_replans: 1,
};
assert_eq!(
replan_decision(&b, StepOutcome::Stalled, 0, 0, true),
ReplanAction::Replan
);
}
}