1use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::PathBuf;
9use std::str::FromStr;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::mode::Mode;
13use crate::stage::Stage;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct State {
18 pub stage: Stage,
20 pub phase: u32,
22 pub agent: AgentKind,
24 pub mode: Mode,
26 #[serde(default)]
28 pub gate_pending: bool,
29 #[serde(default)]
33 pub consecutive_failures: u32,
34 pub started_at: String,
36 pub project_root: PathBuf,
38 #[serde(default)]
43 pub worktree_path: Option<PathBuf>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum AgentKind {
50 Claude,
52 Codex,
54 OpenCode,
56}
57
58impl fmt::Display for AgentKind {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 let name = match self {
61 AgentKind::Claude => "claude",
62 AgentKind::Codex => "codex",
63 AgentKind::OpenCode => "opencode",
64 };
65 f.write_str(name)
66 }
67}
68
69impl FromStr for AgentKind {
70 type Err = AgentParseError;
71
72 fn from_str(value: &str) -> Result<Self, Self::Err> {
73 match value.to_ascii_lowercase().as_str() {
74 "claude" => Ok(AgentKind::Claude),
75 "codex" => Ok(AgentKind::Codex),
76 "opencode" | "open-code" => Ok(AgentKind::OpenCode),
77 other => Err(AgentParseError(other.to_string())),
78 }
79 }
80}
81
82#[derive(Debug, Clone, thiserror::Error)]
84#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
85pub struct AgentParseError(String);
86
87impl State {
88 pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
90 State {
91 stage: Stage::Define,
92 phase,
93 agent,
94 mode,
95 gate_pending: false,
96 consecutive_failures: 0,
97 started_at: timestamp_now(),
98 project_root,
99 worktree_path: None,
100 }
101 }
102}
103
104fn timestamp_now() -> String {
105 match SystemTime::now().duration_since(UNIX_EPOCH) {
106 Ok(duration) => format!("{}", duration.as_secs()),
107 Err(_) => String::from("0"),
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use std::path::PathBuf;
115
116 #[test]
117 fn agent_name_and_display() {
118 use crate::agents::adapter_for;
119 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
120 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
121 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
122
123 assert_eq!(AgentKind::Claude.to_string(), "claude");
124 assert_eq!(AgentKind::Codex.to_string(), "codex");
125 assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
126 }
127
128 #[test]
129 fn agent_from_str_accepts_canonical_and_aliases() {
130 assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
131 assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
132 assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
133 assert_eq!(
134 "opencode".parse::<AgentKind>().unwrap(),
135 AgentKind::OpenCode
136 );
137 assert_eq!(
138 "open-code".parse::<AgentKind>().unwrap(),
139 AgentKind::OpenCode
140 );
141 }
142
143 #[test]
144 fn agent_from_str_rejects_unknown() {
145 let err = "aider".parse::<AgentKind>().unwrap_err();
146 assert!(err.to_string().contains("aider"));
147 }
148
149 #[test]
150 fn new_state_starts_at_define() {
151 let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
152 assert_eq!(state.stage, Stage::Define);
153 assert_eq!(state.phase, 2);
154 assert_eq!(state.agent, AgentKind::Claude);
155 assert_eq!(state.mode, Mode::Auto);
156 assert!(!state.gate_pending);
157 assert_eq!(state.consecutive_failures, 0);
158 assert!(!state.started_at.is_empty());
159 }
160
161 #[test]
162 fn state_serde_round_trips() {
163 let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
164 let json = serde_json::to_string(&state).unwrap();
165 let back: State = serde_json::from_str(&json).unwrap();
166 assert_eq!(back.phase, 9);
167 assert_eq!(back.agent, AgentKind::Codex);
168 assert_eq!(back.stage, Stage::Define);
169 assert_eq!(back.mode, Mode::Supervise);
170 }
171
172 #[test]
173 fn consecutive_failures_persists_across_advance_calls() {
174 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
175 state.consecutive_failures = 3;
176 let json = serde_json::to_string(&state).unwrap();
177 assert!(
178 json.contains("consecutive_failures"),
179 "consecutive_failures must appear in persisted JSON"
180 );
181 let loaded: State = serde_json::from_str(&json).unwrap();
182 assert_eq!(
183 loaded.consecutive_failures, 3,
184 "consecutive_failures must round-trip through serde"
185 );
186 }
187}