1use crate::state::State;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use tracing::{debug, warn};
13
14#[derive(Debug, thiserror::Error)]
16pub enum WorkflowError {
17 #[error("state I/O failed: {0}")]
19 Io(#[from] std::io::Error),
20 #[error("state JSON failed: {0}")]
22 Json(#[from] serde_json::Error),
23 #[error("no active DevFlow state at {0}")]
25 MissingState(PathBuf),
26}
27
28const STATE_FILE_PREFIX: &str = "state-";
31const CORRUPT_LEGACY_STATE_HINT: &str = "devflow recover --clean";
32
33pub fn devflow_dir(project_root: &Path) -> PathBuf {
35 project_root.join(".devflow")
36}
37
38fn find_devflow_marker(dir: &Path) -> Option<PathBuf> {
50 let mut acc = PathBuf::new();
51 for component in dir.components() {
52 acc.push(component.as_os_str());
53 if component.as_os_str() == std::ffi::OsStr::new(".devflow") {
54 return Some(acc);
55 }
56 }
57 None
58}
59
60pub fn ensure_devflow_dir(dir: &Path) -> std::io::Result<()> {
104 std::fs::create_dir_all(dir)?;
105
106 let Some(marker_dir) = find_devflow_marker(dir) else {
107 return Ok(());
108 };
109
110 let gitignore = marker_dir.join(".gitignore");
111 match std::fs::OpenOptions::new()
112 .write(true)
113 .create_new(true)
114 .open(&gitignore)
115 {
116 Ok(mut f) => {
117 f.write_all(b"*\n")?;
118 Ok(())
119 }
120 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
121 Err(err) => Err(err),
122 }
123}
124
125pub fn state_path(project_root: &Path, phase: u32) -> PathBuf {
127 devflow_dir(project_root).join(format!("{STATE_FILE_PREFIX}{phase:02}.json"))
128}
129
130pub(crate) fn legacy_state_path(project_root: &Path) -> PathBuf {
132 devflow_dir(project_root).join("state.json")
133}
134
135fn migrate_legacy_state(project_root: &Path) {
140 let legacy = legacy_state_path(project_root);
141 let Ok(contents) = std::fs::read_to_string(&legacy) else {
142 return;
143 };
144 let Ok(state) = serde_json::from_str::<State>(&contents) else {
145 warn!(
146 "legacy state at {} is unparsable — leaving it in place; remove it with `{CORRUPT_LEGACY_STATE_HINT}`",
147 legacy.display()
148 );
149 return;
150 };
151 let target = state_path(project_root, state.phase);
152 if target.exists() {
153 debug!(
154 "per-phase state already exists for phase {} — dropping stale legacy file",
155 state.phase
156 );
157 } else if let Err(err) = std::fs::rename(&legacy, &target) {
158 warn!(
159 "could not migrate legacy state to {}: {err}",
160 target.display()
161 );
162 return;
163 } else {
164 debug!("migrated legacy state.json to {}", target.display());
165 return;
166 }
167 let _ = std::fs::remove_file(&legacy);
168}
169
170pub fn save_state(state: &State) -> Result<(), WorkflowError> {
172 debug!("saving state: phase={} stage={}", state.phase, state.stage);
173 let path = state_path(&state.project_root, state.phase);
174 let contents = serde_json::to_string_pretty(state)?;
175 write_state_atomic(&path, &contents)?;
176 Ok(())
177}
178
179fn write_state_atomic(path: &Path, contents: &str) -> Result<(), WorkflowError> {
182 if let Some(parent) = path.parent() {
183 ensure_devflow_dir(parent)?;
184 }
185 let tmp = path.with_extension("tmp");
186 std::fs::write(&tmp, contents)?;
187 std::fs::rename(&tmp, path)?;
188 Ok(())
189}
190
191pub fn load_state(project_root: &Path, phase: u32) -> Result<State, WorkflowError> {
193 migrate_legacy_state(project_root);
194 let path = state_path(project_root, phase);
195 debug!("loading state from {}", path.display());
196 if !path.exists() {
197 return Err(WorkflowError::MissingState(path));
198 }
199 let contents = std::fs::read_to_string(&path)?;
200 Ok(serde_json::from_str(&contents)?)
201}
202
203pub fn list_states(project_root: &Path) -> Vec<State> {
209 migrate_legacy_state(project_root);
210 let mut states = Vec::new();
211 let Ok(entries) = std::fs::read_dir(devflow_dir(project_root)) else {
212 return states;
213 };
214 for entry in entries.flatten() {
215 let name = entry.file_name();
216 let Some(name) = name.to_str() else { continue };
217 if !name.starts_with(STATE_FILE_PREFIX) || !name.ends_with(".json") {
218 continue;
219 }
220 match std::fs::read_to_string(entry.path())
221 .map_err(WorkflowError::from)
222 .and_then(|c| Ok(serde_json::from_str::<State>(&c)?))
223 {
224 Ok(state) => states.push(state),
225 Err(err) => warn!("skipping unreadable state file {name}: {err}"),
226 }
227 }
228 states.sort_by_key(|s| s.phase);
229 states
230}
231
232pub fn remove_corrupt_legacy_state(project_root: &Path) -> Result<bool, WorkflowError> {
239 let legacy = legacy_state_path(project_root);
240 let Ok(contents) = std::fs::read_to_string(&legacy) else {
241 return Ok(false);
242 };
243 if serde_json::from_str::<State>(&contents).is_ok() {
244 return Ok(false);
246 }
247 std::fs::remove_file(&legacy)?;
248 warn!("removed unparsable legacy state at {}", legacy.display());
249 Ok(true)
250}
251
252pub fn clear_state(project_root: &Path, phase: u32) -> Result<(), WorkflowError> {
254 let path = state_path(project_root, phase);
255 if path.exists() {
256 debug!("clearing state at {}", path.display());
257 std::fs::remove_file(path)?;
258 }
259 let legacy = legacy_state_path(project_root);
262 if let Ok(contents) = std::fs::read_to_string(&legacy)
263 && let Ok(state) = serde_json::from_str::<State>(&contents)
264 && state.phase == phase
265 {
266 std::fs::remove_file(&legacy)?;
267 }
268 Ok(())
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn migrate_legacy_state_warning_names_recovery_command() {
277 assert!(CORRUPT_LEGACY_STATE_HINT.contains("recover --clean"));
278 }
279
280 #[test]
281 fn ensure_devflow_dir_writes_star_gitignore() {
282 let dir = tempfile::tempdir().unwrap();
283 let target = dir.path().join(".devflow");
284 ensure_devflow_dir(&target).expect("ensure_devflow_dir");
285 assert!(target.is_dir());
286 let contents = std::fs::read_to_string(target.join(".gitignore")).unwrap();
287 assert_eq!(contents.trim(), "*");
288 }
289
290 #[test]
291 fn ensure_devflow_dir_is_idempotent_and_preserves_existing_gitignore() {
292 let dir = tempfile::tempdir().unwrap();
293 let target = dir.path().join(".devflow");
294 ensure_devflow_dir(&target).expect("first call");
295 let first = std::fs::read(target.join(".gitignore")).unwrap();
296
297 ensure_devflow_dir(&target).expect("second call");
298 let second = std::fs::read(target.join(".gitignore")).unwrap();
299 assert_eq!(
300 first, second,
301 "second call must leave the file byte-identical"
302 );
303 }
304
305 #[test]
306 fn ensure_devflow_dir_preserves_foreign_gitignore_content() {
307 let dir = tempfile::tempdir().unwrap();
308 let target = dir.path().join(".devflow");
309 std::fs::create_dir_all(&target).unwrap();
310 std::fs::write(target.join(".gitignore"), "# owned by something else\n").unwrap();
311
312 ensure_devflow_dir(&target).expect("must not fail on a foreign .gitignore");
313
314 let contents = std::fs::read_to_string(target.join(".gitignore")).unwrap();
315 assert_eq!(contents, "# owned by something else\n");
316 }
317
318 #[test]
319 fn ensure_devflow_dir_on_nested_subpath_marks_the_devflow_ancestor() {
320 let dir = tempfile::tempdir().unwrap();
321 let target = dir.path().join(".devflow").join("history").join("phase-01");
322 ensure_devflow_dir(&target).expect("nested ensure_devflow_dir");
323
324 assert!(target.is_dir());
325 let marker = dir.path().join(".devflow").join(".gitignore");
326 assert!(
327 marker.is_file(),
328 "gitignore must land at the .devflow ancestor"
329 );
330 assert!(
331 !target.join(".gitignore").exists(),
332 "gitignore must not land at the leaf directory"
333 );
334 }
335
336 #[test]
341 fn ensure_devflow_dir_on_relative_devflow_leaf_path_marks_it() {
342 assert_eq!(
343 find_devflow_marker(Path::new(".devflow/captures")),
344 Some(PathBuf::from(".devflow")),
345 "leaf-adjacent relative path must mark .devflow, not captures/"
346 );
347 assert_eq!(
348 find_devflow_marker(Path::new(".devflow")),
349 Some(PathBuf::from(".devflow")),
350 "bare relative .devflow path must mark itself"
351 );
352 }
353
354 #[test]
355 fn ensure_devflow_dir_without_a_devflow_ancestor_only_creates_dirs() {
356 let dir = tempfile::tempdir().unwrap();
357 let target = dir.path().join("plain").join("sub");
358 ensure_devflow_dir(&target).expect("ensure_devflow_dir");
359
360 assert!(target.is_dir());
361 assert!(!dir.path().join(".gitignore").exists());
362 assert!(!dir.path().join("plain").join(".gitignore").exists());
363 assert!(!target.join(".gitignore").exists());
364 }
365
366 #[test]
367 fn ensure_devflow_dir_concurrent_calls_both_succeed() {
368 let dir = tempfile::tempdir().unwrap();
369 let target = dir.path().join(".devflow");
370
371 let t1 = {
372 let target = target.clone();
373 std::thread::spawn(move || ensure_devflow_dir(&target))
374 };
375 let t2 = {
376 let target = target.clone();
377 std::thread::spawn(move || ensure_devflow_dir(&target))
378 };
379 assert!(
380 t1.join().unwrap().is_ok(),
381 "first concurrent call must succeed"
382 );
383 assert!(
384 t2.join().unwrap().is_ok(),
385 "second concurrent call must succeed"
386 );
387
388 let contents = std::fs::read_to_string(target.join(".gitignore")).unwrap();
389 assert_eq!(contents.trim(), "*");
390 }
391
392 use crate::mode::Mode;
393 use crate::stage::Stage;
394 use crate::state::AgentKind;
395
396 fn state_in(root: &Path, phase: u32, stage: Stage) -> State {
397 let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
398 state.stage = stage;
399 state
400 }
401
402 #[test]
403 fn paths_are_per_phase_under_devflow_dir() {
404 let root = Path::new("/repo");
405 assert_eq!(devflow_dir(root), Path::new("/repo/.devflow"));
406 assert_eq!(
407 state_path(root, 7),
408 Path::new("/repo/.devflow/state-07.json")
409 );
410 assert_eq!(
411 state_path(root, 14),
412 Path::new("/repo/.devflow/state-14.json")
413 );
414 }
415
416 #[test]
417 fn save_then_load_round_trips() {
418 let dir = tempfile::tempdir().unwrap();
419 let state = state_in(dir.path(), 1, Stage::Code);
420 save_state(&state).expect("save");
421
422 let loaded = load_state(dir.path(), 1).expect("load");
423 assert_eq!(loaded.stage, Stage::Code);
424 assert_eq!(loaded.phase, 1);
425 assert_eq!(loaded.agent, AgentKind::Claude);
426 assert_eq!(loaded.mode, Mode::Auto);
427 }
428
429 #[test]
432 fn two_phases_states_coexist_without_clobbering() {
433 let dir = tempfile::tempdir().unwrap();
434 save_state(&state_in(dir.path(), 13, Stage::Code)).unwrap();
435 save_state(&state_in(dir.path(), 14, Stage::Validate)).unwrap();
436
437 let a = load_state(dir.path(), 13).expect("phase 13 state");
438 let b = load_state(dir.path(), 14).expect("phase 14 state");
439 assert_eq!(a.phase, 13);
440 assert_eq!(a.stage, Stage::Code);
441 assert_eq!(b.phase, 14);
442 assert_eq!(b.stage, Stage::Validate);
443 }
444
445 #[test]
446 fn save_state_writes_atomically_and_leaves_no_temp() {
447 let dir = tempfile::tempdir().unwrap();
448 let state = state_in(dir.path(), 1, Stage::Validate);
449
450 save_state(&state).expect("save");
451
452 let path = state_path(dir.path(), 1);
453 assert!(path.exists());
454 let loaded = load_state(dir.path(), 1).expect("load");
455 assert_eq!(loaded.stage, Stage::Validate);
456 assert_eq!(loaded.phase, state.phase);
457 assert!(!path.with_extension("tmp").exists());
458 }
459
460 #[test]
461 fn load_missing_state_errors() {
462 let dir = tempfile::tempdir().unwrap();
463 let err = load_state(dir.path(), 1).unwrap_err();
464 assert!(matches!(err, WorkflowError::MissingState(_)));
465 }
466
467 #[test]
468 fn clear_removes_state_and_is_idempotent() {
469 let dir = tempfile::tempdir().unwrap();
470 let state = state_in(dir.path(), 1, Stage::Validate);
471 save_state(&state).unwrap();
472 assert!(state_path(dir.path(), 1).exists());
473
474 clear_state(dir.path(), 1).expect("clear");
475 assert!(!state_path(dir.path(), 1).exists());
476 clear_state(dir.path(), 1).expect("clear again");
478 }
479
480 #[test]
481 fn clear_only_touches_its_own_phase() {
482 let dir = tempfile::tempdir().unwrap();
483 save_state(&state_in(dir.path(), 13, Stage::Code)).unwrap();
484 save_state(&state_in(dir.path(), 14, Stage::Ship)).unwrap();
485
486 clear_state(dir.path(), 13).unwrap();
487
488 assert!(!state_path(dir.path(), 13).exists());
489 assert!(load_state(dir.path(), 14).is_ok(), "phase 14 must survive");
490 }
491
492 #[test]
493 fn list_states_enumerates_sorted_by_phase() {
494 let dir = tempfile::tempdir().unwrap();
495 save_state(&state_in(dir.path(), 14, Stage::Ship)).unwrap();
496 save_state(&state_in(dir.path(), 3, Stage::Code)).unwrap();
497
498 let states = list_states(dir.path());
499 assert_eq!(
500 states.iter().map(|s| s.phase).collect::<Vec<_>>(),
501 vec![3, 14]
502 );
503 }
504
505 #[test]
506 fn list_states_empty_when_no_devflow_dir() {
507 let dir = tempfile::tempdir().unwrap();
508 assert!(list_states(dir.path()).is_empty());
509 }
510
511 #[test]
512 fn list_states_skips_corrupt_files() {
513 let dir = tempfile::tempdir().unwrap();
514 save_state(&state_in(dir.path(), 5, Stage::Code)).unwrap();
515 std::fs::write(state_path(dir.path(), 6), "not json").unwrap();
516
517 let states = list_states(dir.path());
518 assert_eq!(states.len(), 1);
519 assert_eq!(states[0].phase, 5);
520 }
521
522 #[test]
526 fn legacy_state_json_migrates_on_load() {
527 let dir = tempfile::tempdir().unwrap();
528 let state = state_in(dir.path(), 9, Stage::Validate);
529 let legacy = legacy_state_path(dir.path());
530 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
531 std::fs::write(&legacy, serde_json::to_string_pretty(&state).unwrap()).unwrap();
532
533 let loaded = load_state(dir.path(), 9).expect("legacy state must migrate");
534 assert_eq!(loaded.phase, 9);
535 assert_eq!(loaded.stage, Stage::Validate);
536 assert!(!legacy.exists(), "legacy file must be gone after migration");
537 assert!(state_path(dir.path(), 9).exists());
538 }
539
540 #[test]
541 fn legacy_state_json_migrates_on_list() {
542 let dir = tempfile::tempdir().unwrap();
543 let state = state_in(dir.path(), 4, Stage::Code);
544 let legacy = legacy_state_path(dir.path());
545 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
546 std::fs::write(&legacy, serde_json::to_string_pretty(&state).unwrap()).unwrap();
547
548 let states = list_states(dir.path());
549 assert_eq!(states.len(), 1);
550 assert_eq!(states[0].phase, 4);
551 assert!(!legacy.exists());
552 }
553
554 #[test]
555 fn legacy_migration_never_overwrites_existing_per_phase_state() {
556 let dir = tempfile::tempdir().unwrap();
557 save_state(&state_in(dir.path(), 9, Stage::Ship)).unwrap();
559 let legacy = legacy_state_path(dir.path());
561 std::fs::write(
562 &legacy,
563 serde_json::to_string_pretty(&state_in(dir.path(), 9, Stage::Code)).unwrap(),
564 )
565 .unwrap();
566
567 let loaded = load_state(dir.path(), 9).unwrap();
568 assert_eq!(loaded.stage, Stage::Ship, "per-phase state must win");
569 assert!(!legacy.exists(), "stale legacy file must be dropped");
570 }
571}