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 #[serde(default)]
43 pub infra_failures: u32,
44 pub started_at: String,
46 pub project_root: PathBuf,
48 #[serde(default)]
53 pub worktree_path: Option<PathBuf>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum AgentKind {
60 Claude,
62 Codex,
64 OpenCode,
66}
67
68impl fmt::Display for AgentKind {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 let name = match self {
71 AgentKind::Claude => "claude",
72 AgentKind::Codex => "codex",
73 AgentKind::OpenCode => "opencode",
74 };
75 f.write_str(name)
76 }
77}
78
79impl FromStr for AgentKind {
80 type Err = AgentParseError;
81
82 fn from_str(value: &str) -> Result<Self, Self::Err> {
83 match value.to_ascii_lowercase().as_str() {
84 "claude" => Ok(AgentKind::Claude),
85 "codex" => Ok(AgentKind::Codex),
86 "opencode" | "open-code" => Ok(AgentKind::OpenCode),
87 other => Err(AgentParseError(other.to_string())),
88 }
89 }
90}
91
92#[derive(Debug, Clone, thiserror::Error)]
94#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
95pub struct AgentParseError(String);
96
97impl State {
98 pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
100 State {
101 stage: Stage::Define,
102 phase,
103 agent,
104 mode,
105 gate_pending: false,
106 consecutive_failures: 0,
107 infra_failures: 0,
108 started_at: timestamp_now(),
109 project_root,
110 worktree_path: None,
111 }
112 }
113}
114
115fn timestamp_now() -> String {
116 match SystemTime::now().duration_since(UNIX_EPOCH) {
117 Ok(duration) => format!("{}", duration.as_secs()),
118 Err(_) => String::from("0"),
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use std::path::PathBuf;
126
127 #[test]
128 fn agent_name_and_display() {
129 use crate::agents::adapter_for;
130 assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
131 assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
132 assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
133
134 assert_eq!(AgentKind::Claude.to_string(), "claude");
135 assert_eq!(AgentKind::Codex.to_string(), "codex");
136 assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
137 }
138
139 #[test]
140 fn agent_from_str_accepts_canonical_and_aliases() {
141 assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
142 assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
143 assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
144 assert_eq!(
145 "opencode".parse::<AgentKind>().unwrap(),
146 AgentKind::OpenCode
147 );
148 assert_eq!(
149 "open-code".parse::<AgentKind>().unwrap(),
150 AgentKind::OpenCode
151 );
152 }
153
154 #[test]
155 fn agent_from_str_rejects_unknown() {
156 let err = "aider".parse::<AgentKind>().unwrap_err();
157 assert!(err.to_string().contains("aider"));
158 }
159
160 #[test]
161 fn new_state_starts_at_define() {
162 let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
163 assert_eq!(state.stage, Stage::Define);
164 assert_eq!(state.phase, 2);
165 assert_eq!(state.agent, AgentKind::Claude);
166 assert_eq!(state.mode, Mode::Auto);
167 assert!(!state.gate_pending);
168 assert_eq!(state.consecutive_failures, 0);
169 assert_eq!(state.infra_failures, 0);
170 assert!(!state.started_at.is_empty());
171 }
172
173 #[test]
174 fn state_serde_round_trips() {
175 let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
176 let json = serde_json::to_string(&state).unwrap();
177 let back: State = serde_json::from_str(&json).unwrap();
178 assert_eq!(back.phase, 9);
179 assert_eq!(back.agent, AgentKind::Codex);
180 assert_eq!(back.stage, Stage::Define);
181 assert_eq!(back.mode, Mode::Supervise);
182 }
183
184 #[test]
185 fn consecutive_failures_persists_across_advance_calls() {
186 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
187 state.consecutive_failures = 3;
188 let json = serde_json::to_string(&state).unwrap();
189 assert!(
190 json.contains("consecutive_failures"),
191 "consecutive_failures must appear in persisted JSON"
192 );
193 let loaded: State = serde_json::from_str(&json).unwrap();
194 assert_eq!(
195 loaded.consecutive_failures, 3,
196 "consecutive_failures must round-trip through serde"
197 );
198 }
199
200 #[test]
203 fn infra_failures_round_trips_through_serde() {
204 let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
205 state.infra_failures = 4;
206 let json = serde_json::to_string(&state).unwrap();
207 assert!(
208 json.contains("infra_failures"),
209 "infra_failures must appear in persisted JSON"
210 );
211 let loaded: State = serde_json::from_str(&json).unwrap();
212 assert_eq!(
213 loaded.infra_failures, 4,
214 "infra_failures must round-trip through serde"
215 );
216 }
217
218 #[test]
221 fn infra_failures_absent_from_json_defaults_to_zero() {
222 let json = r#"{
223 "stage": "code",
224 "phase": 1,
225 "agent": "claude",
226 "mode": "auto",
227 "started_at": "0",
228 "project_root": "/repo"
229 }"#;
230 let loaded: State = serde_json::from_str(json).unwrap();
231 assert_eq!(loaded.infra_failures, 0);
232 }
233}