1use crate::state::State;
10use std::path::{Path, PathBuf};
11use tracing::{debug, warn};
12
13#[derive(Debug, thiserror::Error)]
15pub enum WorkflowError {
16 #[error("state I/O failed: {0}")]
18 Io(#[from] std::io::Error),
19 #[error("state JSON failed: {0}")]
21 Json(#[from] serde_json::Error),
22 #[error("no active DevFlow state at {0}")]
24 MissingState(PathBuf),
25}
26
27const STATE_FILE_PREFIX: &str = "state-";
30
31pub fn devflow_dir(project_root: &Path) -> PathBuf {
33 project_root.join(".devflow")
34}
35
36pub fn state_path(project_root: &Path, phase: u32) -> PathBuf {
38 devflow_dir(project_root).join(format!("{STATE_FILE_PREFIX}{phase:02}.json"))
39}
40
41fn legacy_state_path(project_root: &Path) -> PathBuf {
43 devflow_dir(project_root).join("state.json")
44}
45
46fn migrate_legacy_state(project_root: &Path) {
51 let legacy = legacy_state_path(project_root);
52 let Ok(contents) = std::fs::read_to_string(&legacy) else {
53 return;
54 };
55 let Ok(state) = serde_json::from_str::<State>(&contents) else {
56 warn!(
57 "legacy state at {} is unparsable — leaving it in place",
58 legacy.display()
59 );
60 return;
61 };
62 let target = state_path(project_root, state.phase);
63 if target.exists() {
64 debug!(
65 "per-phase state already exists for phase {} — dropping stale legacy file",
66 state.phase
67 );
68 } else if let Err(err) = std::fs::rename(&legacy, &target) {
69 warn!(
70 "could not migrate legacy state to {}: {err}",
71 target.display()
72 );
73 return;
74 } else {
75 debug!("migrated legacy state.json to {}", target.display());
76 return;
77 }
78 let _ = std::fs::remove_file(&legacy);
79}
80
81pub fn save_state(state: &State) -> Result<(), WorkflowError> {
83 debug!("saving state: phase={} stage={}", state.phase, state.stage);
84 let path = state_path(&state.project_root, state.phase);
85 let contents = serde_json::to_string_pretty(state)?;
86 write_state_atomic(&path, &contents)?;
87 Ok(())
88}
89
90fn write_state_atomic(path: &Path, contents: &str) -> Result<(), WorkflowError> {
93 if let Some(parent) = path.parent() {
94 std::fs::create_dir_all(parent)?;
95 }
96 let tmp = path.with_extension("tmp");
97 std::fs::write(&tmp, contents)?;
98 std::fs::rename(&tmp, path)?;
99 Ok(())
100}
101
102pub fn load_state(project_root: &Path, phase: u32) -> Result<State, WorkflowError> {
104 migrate_legacy_state(project_root);
105 let path = state_path(project_root, phase);
106 debug!("loading state from {}", path.display());
107 if !path.exists() {
108 return Err(WorkflowError::MissingState(path));
109 }
110 let contents = std::fs::read_to_string(&path)?;
111 Ok(serde_json::from_str(&contents)?)
112}
113
114pub fn list_states(project_root: &Path) -> Vec<State> {
120 migrate_legacy_state(project_root);
121 let mut states = Vec::new();
122 let Ok(entries) = std::fs::read_dir(devflow_dir(project_root)) else {
123 return states;
124 };
125 for entry in entries.flatten() {
126 let name = entry.file_name();
127 let Some(name) = name.to_str() else { continue };
128 if !name.starts_with(STATE_FILE_PREFIX) || !name.ends_with(".json") {
129 continue;
130 }
131 match std::fs::read_to_string(entry.path())
132 .map_err(WorkflowError::from)
133 .and_then(|c| Ok(serde_json::from_str::<State>(&c)?))
134 {
135 Ok(state) => states.push(state),
136 Err(err) => warn!("skipping unreadable state file {name}: {err}"),
137 }
138 }
139 states.sort_by_key(|s| s.phase);
140 states
141}
142
143pub fn remove_corrupt_legacy_state(project_root: &Path) -> Result<bool, WorkflowError> {
150 let legacy = legacy_state_path(project_root);
151 let Ok(contents) = std::fs::read_to_string(&legacy) else {
152 return Ok(false);
153 };
154 if serde_json::from_str::<State>(&contents).is_ok() {
155 return Ok(false);
157 }
158 std::fs::remove_file(&legacy)?;
159 warn!("removed unparsable legacy state at {}", legacy.display());
160 Ok(true)
161}
162
163pub fn clear_state(project_root: &Path, phase: u32) -> Result<(), WorkflowError> {
165 let path = state_path(project_root, phase);
166 if path.exists() {
167 debug!("clearing state at {}", path.display());
168 std::fs::remove_file(path)?;
169 }
170 let legacy = legacy_state_path(project_root);
173 if let Ok(contents) = std::fs::read_to_string(&legacy)
174 && let Ok(state) = serde_json::from_str::<State>(&contents)
175 && state.phase == phase
176 {
177 std::fs::remove_file(&legacy)?;
178 }
179 Ok(())
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::mode::Mode;
186 use crate::stage::Stage;
187 use crate::state::AgentKind;
188
189 fn state_in(root: &Path, phase: u32, stage: Stage) -> State {
190 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
191 state.stage = stage;
192 state
193 }
194
195 #[test]
196 fn paths_are_per_phase_under_devflow_dir() {
197 let root = Path::new("/repo");
198 assert_eq!(devflow_dir(root), Path::new("/repo/.devflow"));
199 assert_eq!(
200 state_path(root, 7),
201 Path::new("/repo/.devflow/state-07.json")
202 );
203 assert_eq!(
204 state_path(root, 14),
205 Path::new("/repo/.devflow/state-14.json")
206 );
207 }
208
209 #[test]
210 fn save_then_load_round_trips() {
211 let dir = tempfile::tempdir().unwrap();
212 let state = state_in(dir.path(), 1, Stage::Code);
213 save_state(&state).expect("save");
214
215 let loaded = load_state(dir.path(), 1).expect("load");
216 assert_eq!(loaded.stage, Stage::Code);
217 assert_eq!(loaded.phase, 1);
218 assert_eq!(loaded.agent, AgentKind::Claude);
219 assert_eq!(loaded.mode, Mode::Auto);
220 }
221
222 #[test]
225 fn two_phases_states_coexist_without_clobbering() {
226 let dir = tempfile::tempdir().unwrap();
227 save_state(&state_in(dir.path(), 13, Stage::Code)).unwrap();
228 save_state(&state_in(dir.path(), 14, Stage::Validate)).unwrap();
229
230 let a = load_state(dir.path(), 13).expect("phase 13 state");
231 let b = load_state(dir.path(), 14).expect("phase 14 state");
232 assert_eq!(a.phase, 13);
233 assert_eq!(a.stage, Stage::Code);
234 assert_eq!(b.phase, 14);
235 assert_eq!(b.stage, Stage::Validate);
236 }
237
238 #[test]
239 fn save_state_writes_atomically_and_leaves_no_temp() {
240 let dir = tempfile::tempdir().unwrap();
241 let state = state_in(dir.path(), 1, Stage::Validate);
242
243 save_state(&state).expect("save");
244
245 let path = state_path(dir.path(), 1);
246 assert!(path.exists());
247 let loaded = load_state(dir.path(), 1).expect("load");
248 assert_eq!(loaded.stage, Stage::Validate);
249 assert_eq!(loaded.phase, state.phase);
250 assert!(!path.with_extension("tmp").exists());
251 }
252
253 #[test]
254 fn load_missing_state_errors() {
255 let dir = tempfile::tempdir().unwrap();
256 let err = load_state(dir.path(), 1).unwrap_err();
257 assert!(matches!(err, WorkflowError::MissingState(_)));
258 }
259
260 #[test]
261 fn clear_removes_state_and_is_idempotent() {
262 let dir = tempfile::tempdir().unwrap();
263 let state = state_in(dir.path(), 1, Stage::Validate);
264 save_state(&state).unwrap();
265 assert!(state_path(dir.path(), 1).exists());
266
267 clear_state(dir.path(), 1).expect("clear");
268 assert!(!state_path(dir.path(), 1).exists());
269 clear_state(dir.path(), 1).expect("clear again");
271 }
272
273 #[test]
274 fn clear_only_touches_its_own_phase() {
275 let dir = tempfile::tempdir().unwrap();
276 save_state(&state_in(dir.path(), 13, Stage::Code)).unwrap();
277 save_state(&state_in(dir.path(), 14, Stage::Ship)).unwrap();
278
279 clear_state(dir.path(), 13).unwrap();
280
281 assert!(!state_path(dir.path(), 13).exists());
282 assert!(load_state(dir.path(), 14).is_ok(), "phase 14 must survive");
283 }
284
285 #[test]
286 fn list_states_enumerates_sorted_by_phase() {
287 let dir = tempfile::tempdir().unwrap();
288 save_state(&state_in(dir.path(), 14, Stage::Ship)).unwrap();
289 save_state(&state_in(dir.path(), 3, Stage::Code)).unwrap();
290
291 let states = list_states(dir.path());
292 assert_eq!(
293 states.iter().map(|s| s.phase).collect::<Vec<_>>(),
294 vec![3, 14]
295 );
296 }
297
298 #[test]
299 fn list_states_empty_when_no_devflow_dir() {
300 let dir = tempfile::tempdir().unwrap();
301 assert!(list_states(dir.path()).is_empty());
302 }
303
304 #[test]
305 fn list_states_skips_corrupt_files() {
306 let dir = tempfile::tempdir().unwrap();
307 save_state(&state_in(dir.path(), 5, Stage::Code)).unwrap();
308 std::fs::write(state_path(dir.path(), 6), "not json").unwrap();
309
310 let states = list_states(dir.path());
311 assert_eq!(states.len(), 1);
312 assert_eq!(states[0].phase, 5);
313 }
314
315 #[test]
319 fn legacy_state_json_migrates_on_load() {
320 let dir = tempfile::tempdir().unwrap();
321 let state = state_in(dir.path(), 9, Stage::Validate);
322 let legacy = legacy_state_path(dir.path());
323 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
324 std::fs::write(&legacy, serde_json::to_string_pretty(&state).unwrap()).unwrap();
325
326 let loaded = load_state(dir.path(), 9).expect("legacy state must migrate");
327 assert_eq!(loaded.phase, 9);
328 assert_eq!(loaded.stage, Stage::Validate);
329 assert!(!legacy.exists(), "legacy file must be gone after migration");
330 assert!(state_path(dir.path(), 9).exists());
331 }
332
333 #[test]
334 fn legacy_state_json_migrates_on_list() {
335 let dir = tempfile::tempdir().unwrap();
336 let state = state_in(dir.path(), 4, Stage::Code);
337 let legacy = legacy_state_path(dir.path());
338 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
339 std::fs::write(&legacy, serde_json::to_string_pretty(&state).unwrap()).unwrap();
340
341 let states = list_states(dir.path());
342 assert_eq!(states.len(), 1);
343 assert_eq!(states[0].phase, 4);
344 assert!(!legacy.exists());
345 }
346
347 #[test]
348 fn legacy_migration_never_overwrites_existing_per_phase_state() {
349 let dir = tempfile::tempdir().unwrap();
350 save_state(&state_in(dir.path(), 9, Stage::Ship)).unwrap();
352 let legacy = legacy_state_path(dir.path());
354 std::fs::write(
355 &legacy,
356 serde_json::to_string_pretty(&state_in(dir.path(), 9, Stage::Code)).unwrap(),
357 )
358 .unwrap();
359
360 let loaded = load_state(dir.path(), 9).unwrap();
361 assert_eq!(loaded.stage, Stage::Ship, "per-phase state must win");
362 assert!(!legacy.exists(), "stale legacy file must be dropped");
363 }
364}