use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::mode::Mode;
use crate::stage::Stage;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct State {
pub stage: Stage,
pub phase: u32,
pub agent: AgentKind,
pub mode: Mode,
#[serde(default)]
pub gate_pending: bool,
#[serde(default)]
pub consecutive_failures: u32,
pub started_at: String,
pub project_root: PathBuf,
#[serde(default)]
pub worktree_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentKind {
Claude,
Codex,
OpenCode,
}
impl fmt::Display for AgentKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
AgentKind::Claude => "claude",
AgentKind::Codex => "codex",
AgentKind::OpenCode => "opencode",
};
f.write_str(name)
}
}
impl FromStr for AgentKind {
type Err = AgentParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"claude" => Ok(AgentKind::Claude),
"codex" => Ok(AgentKind::Codex),
"opencode" | "open-code" => Ok(AgentKind::OpenCode),
other => Err(AgentParseError(other.to_string())),
}
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
pub struct AgentParseError(String);
impl State {
pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
State {
stage: Stage::Define,
phase,
agent,
mode,
gate_pending: false,
consecutive_failures: 0,
started_at: timestamp_now(),
project_root,
worktree_path: None,
}
}
}
fn timestamp_now() -> String {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => format!("{}", duration.as_secs()),
Err(_) => String::from("0"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn agent_name_and_display() {
use crate::agents::adapter_for;
assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
assert_eq!(AgentKind::Claude.to_string(), "claude");
assert_eq!(AgentKind::Codex.to_string(), "codex");
assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
}
#[test]
fn agent_from_str_accepts_canonical_and_aliases() {
assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
assert_eq!(
"opencode".parse::<AgentKind>().unwrap(),
AgentKind::OpenCode
);
assert_eq!(
"open-code".parse::<AgentKind>().unwrap(),
AgentKind::OpenCode
);
}
#[test]
fn agent_from_str_rejects_unknown() {
let err = "aider".parse::<AgentKind>().unwrap_err();
assert!(err.to_string().contains("aider"));
}
#[test]
fn new_state_starts_at_define() {
let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
assert_eq!(state.stage, Stage::Define);
assert_eq!(state.phase, 2);
assert_eq!(state.agent, AgentKind::Claude);
assert_eq!(state.mode, Mode::Auto);
assert!(!state.gate_pending);
assert_eq!(state.consecutive_failures, 0);
assert!(!state.started_at.is_empty());
}
#[test]
fn state_serde_round_trips() {
let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
let json = serde_json::to_string(&state).unwrap();
let back: State = serde_json::from_str(&json).unwrap();
assert_eq!(back.phase, 9);
assert_eq!(back.agent, AgentKind::Codex);
assert_eq!(back.stage, Stage::Define);
assert_eq!(back.mode, Mode::Supervise);
}
#[test]
fn consecutive_failures_persists_across_advance_calls() {
let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
state.consecutive_failures = 3;
let json = serde_json::to_string(&state).unwrap();
assert!(
json.contains("consecutive_failures"),
"consecutive_failures must appear in persisted JSON"
);
let loaded: State = serde_json::from_str(&json).unwrap();
assert_eq!(
loaded.consecutive_failures, 3,
"consecutive_failures must round-trip through serde"
);
}
}