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