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