use std::fs;
use std::path::{Path, PathBuf};
use crate::state::types::{PersistedRunState, RunSummary, SafeBoundary};
const RUNS_DIR: &str = ".7/runs";
fn runs_dir(project_root: &str) -> PathBuf {
Path::new(project_root).join(RUNS_DIR)
}
fn run_file_path(run_id: &str, project_root: &str) -> PathBuf {
runs_dir(project_root).join(run_id).join("state.json")
}
pub fn validate_state(state: &PersistedRunState) -> Result<(), String> {
if state.schema_version != 1 {
return Err(format!(
"Invalid schema version: expected 1, got {}",
state.schema_version
));
}
if state.run_id.is_empty() {
return Err("Missing required field: runId".to_string());
}
if state.timestamp.is_empty() {
return Err("Missing required field: timestamp".to_string());
}
if state.root_workflow.is_empty() {
return Err("Missing required field: rootWorkflow".to_string());
}
if state.current_boundary_index < -1 {
return Err(format!(
"currentBoundaryIndex must be >= -1, got {}",
state.current_boundary_index
));
}
Ok(())
}
pub fn save_state(state: &PersistedRunState, project_root: &str) -> Result<(), String> {
validate_state(state)?;
let file_path = run_file_path(&state.run_id, project_root);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create run directory: {}", e))?;
}
let temp_path =
file_path.with_extension(format!("tmp.{}", chrono::Utc::now().timestamp_millis()));
let json = serde_json::to_string_pretty(state)
.map_err(|e| format!("Failed to serialize state: {}", e))?;
fs::write(&temp_path, &json).map_err(|e| format!("Failed to write temp file: {}", e))?;
fs::rename(&temp_path, &file_path).map_err(|e| format!("Failed to rename temp file: {}", e))?;
Ok(())
}
pub fn load_state(run_id: &str, project_root: &str) -> Result<PersistedRunState, String> {
let file_path = run_file_path(run_id, project_root);
if !file_path.exists() {
return Err(format!("Run not found: {}", run_id));
}
let raw = fs::read_to_string(&file_path).map_err(|_| format!("Run not found: {}", run_id))?;
let parsed: PersistedRunState = serde_json::from_str(&raw)
.map_err(|e| format!("Corrupt state for run {}: {}", run_id, e))?;
validate_state(&parsed)?;
Ok(parsed)
}
pub fn list_runs(project_root: &str) -> Result<Vec<RunSummary>, String> {
let dir = runs_dir(project_root);
if !dir.exists() {
return Ok(Vec::new());
}
let entries =
fs::read_dir(&dir).map_err(|e| format!("Failed to read runs directory: {}", e))?;
let mut summaries: Vec<RunSummary> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.is_dir() {
let state_file = path.join("state.json");
if state_file.exists() {
if let Ok(raw) = fs::read_to_string(&state_file) {
if let Ok(parsed) = serde_json::from_str::<PersistedRunState>(&raw) {
summaries.push(RunSummary {
run_id: parsed.run_id,
timestamp: parsed.timestamp,
root_workflow: parsed.root_workflow,
status: parsed.status,
});
}
}
}
}
}
summaries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(summaries)
}
pub fn get_safe_boundaries(run_id: &str, project_root: &str) -> Result<Vec<SafeBoundary>, String> {
let state = load_state(run_id, project_root)?;
Ok(state.safe_boundaries)
}
pub fn validate_boundary(
run_id: &str,
target_index: usize,
project_root: &str,
) -> Result<(), String> {
let state = load_state(run_id, project_root)?;
if state.safe_boundaries.is_empty() {
return Err(format!("Run {} has no safe boundaries", run_id));
}
if target_index >= state.safe_boundaries.len() {
return Err(format!(
"Boundary index {} out of range [0, {}]",
target_index,
state.safe_boundaries.len() - 1
));
}
if (target_index as i64) > state.current_boundary_index {
return Err(format!(
"Cannot move forward: target index {} is ahead of current index {}",
target_index, state.current_boundary_index
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::types::*;
use std::collections::HashMap;
use tempfile::TempDir;
fn make_test_state(run_id: &str) -> PersistedRunState {
PersistedRunState {
schema_version: 1,
run_id: run_id.to_string(),
timestamp: "2026-04-09T00:00:00Z".to_string(),
root_workflow: "main".to_string(),
status: RunStatus::Completed,
call_stack: vec![CallStackEntry {
workflow: "main".to_string(),
step_index: 0,
}],
steps: HashMap::new(),
branches: None,
collected_outputs: None,
harness_event_log: None,
safe_boundaries: Vec::new(),
current_boundary_index: -1,
event_log: None,
}
}
#[test]
fn test_save_and_load() {
let dir = TempDir::new().unwrap();
let state = make_test_state("test-run-1");
save_state(&state, dir.path().to_str().unwrap()).unwrap();
let loaded = load_state("test-run-1", dir.path().to_str().unwrap()).unwrap();
assert_eq!(loaded.run_id, "test-run-1");
assert_eq!(loaded.root_workflow, "main");
assert_eq!(loaded.status, RunStatus::Completed);
assert_eq!(loaded.schema_version, 1);
}
#[test]
fn test_load_nonexistent() {
let dir = TempDir::new().unwrap();
let result = load_state("nonexistent", dir.path().to_str().unwrap());
assert!(result.is_err());
assert!(result.unwrap_err().contains("Run not found"));
}
#[test]
fn test_list_runs() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
save_state(&make_test_state("run-a"), root).unwrap();
save_state(&make_test_state("run-b"), root).unwrap();
let summaries = list_runs(root).unwrap();
assert_eq!(summaries.len(), 2);
}
#[test]
fn test_list_runs_empty_dir() {
let dir = TempDir::new().unwrap();
let summaries = list_runs(dir.path().to_str().unwrap()).unwrap();
assert!(summaries.is_empty());
}
#[test]
fn test_list_runs_ignores_non_directory_entries() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
save_state(&make_test_state("run-real"), root).unwrap();
let runs = runs_dir(root);
fs::write(runs.join("stray.json"), r#"{"not":"valid"}"#).unwrap();
let summaries = list_runs(root).unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].run_id, "run-real");
}
#[test]
fn test_list_runs_sorted_by_timestamp() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
let mut state_a = make_test_state("run-a");
state_a.timestamp = "2026-04-09T00:00:00Z".to_string();
save_state(&state_a, root).unwrap();
let mut state_b = make_test_state("run-b");
state_b.timestamp = "2026-04-09T01:00:00Z".to_string();
save_state(&state_b, root).unwrap();
let summaries = list_runs(root).unwrap();
assert_eq!(summaries.len(), 2);
assert_eq!(summaries[0].run_id, "run-b");
assert_eq!(summaries[1].run_id, "run-a");
}
#[test]
fn test_validate_state_bad_schema_version() {
let mut state = make_test_state("run-1");
state.schema_version = 99;
let result = validate_state(&state);
assert!(result.is_err());
assert!(result.unwrap_err().contains("schema version"));
}
#[test]
fn test_validate_state_empty_run_id() {
let mut state = make_test_state("run-1");
state.run_id = String::new();
let result = validate_state(&state);
assert!(result.is_err());
assert!(result.unwrap_err().contains("runId"));
}
#[test]
fn test_validate_state_bad_boundary_index() {
let mut state = make_test_state("run-1");
state.current_boundary_index = -2;
let result = validate_state(&state);
assert!(result.is_err());
assert!(result.unwrap_err().contains("currentBoundaryIndex"));
}
#[test]
fn test_get_safe_boundaries() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
let mut state = make_test_state("run-boundaries");
state.safe_boundaries = vec![
SafeBoundary {
boundary_type: SafeBoundaryType::BeforeStepStart,
step_path: vec!["main".to_string()],
timestamp: None,
index: 0,
},
SafeBoundary {
boundary_type: SafeBoundaryType::AfterStepComplete,
step_path: vec!["main".to_string()],
timestamp: None,
index: 1,
},
];
state.current_boundary_index = 1;
save_state(&state, root).unwrap();
let boundaries = get_safe_boundaries("run-boundaries", root).unwrap();
assert_eq!(boundaries.len(), 2);
}
#[test]
fn test_validate_boundary_valid() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
let mut state = make_test_state("run-vb");
state.safe_boundaries = vec![
SafeBoundary {
boundary_type: SafeBoundaryType::BeforeStepStart,
step_path: vec!["main".to_string()],
timestamp: None,
index: 0,
},
SafeBoundary {
boundary_type: SafeBoundaryType::AfterStepComplete,
step_path: vec!["main".to_string()],
timestamp: None,
index: 1,
},
];
state.current_boundary_index = 1;
save_state(&state, root).unwrap();
assert!(validate_boundary("run-vb", 0, root).is_ok());
assert!(validate_boundary("run-vb", 1, root).is_ok());
}
#[test]
fn test_validate_boundary_no_boundaries() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
let state = make_test_state("run-nb");
save_state(&state, root).unwrap();
let result = validate_boundary("run-nb", 0, root);
assert!(result.is_err());
assert!(result.unwrap_err().contains("no safe boundaries"));
}
#[test]
fn test_validate_boundary_out_of_range() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
let mut state = make_test_state("run-oor");
state.safe_boundaries = vec![SafeBoundary {
boundary_type: SafeBoundaryType::BeforeStepStart,
step_path: vec!["main".to_string()],
timestamp: None,
index: 0,
}];
state.current_boundary_index = 0;
save_state(&state, root).unwrap();
let result = validate_boundary("run-oor", 5, root);
assert!(result.is_err());
assert!(result.unwrap_err().contains("out of range"));
}
#[test]
fn test_validate_boundary_forward_not_allowed() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
let mut state = make_test_state("run-fwd");
state.safe_boundaries = vec![
SafeBoundary {
boundary_type: SafeBoundaryType::BeforeStepStart,
step_path: vec!["main".to_string()],
timestamp: None,
index: 0,
},
SafeBoundary {
boundary_type: SafeBoundaryType::AfterStepComplete,
step_path: vec!["main".to_string()],
timestamp: None,
index: 1,
},
];
state.current_boundary_index = 0;
save_state(&state, root).unwrap();
let result = validate_boundary("run-fwd", 1, root);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Cannot move forward"));
}
#[test]
fn test_save_state_creates_runs_dir() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
let runs = runs_dir(root);
assert!(!runs.exists());
save_state(&make_test_state("run-mkdir"), root).unwrap();
assert!(runs.exists());
}
#[test]
fn test_save_load_roundtrip_with_steps() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_str().unwrap();
let mut state = make_test_state("run-steps");
state.steps.insert(
"main/deploy".to_string(),
StepStatePersisted {
name: "deploy".to_string(),
kind: StepKind::Exec,
status: StepStatusPersisted::Completed,
exec_meta: Some(ExecMeta {
harness: Some("claude".to_string()),
prompt_ref: None,
args: None,
}),
check_result: None,
failure_details: None,
started_at: Some("2026-04-09T00:00:01Z".to_string()),
completed_at: Some("2026-04-09T00:00:02Z".to_string()),
},
);
save_state(&state, root).unwrap();
let loaded = load_state("run-steps", root).unwrap();
assert_eq!(loaded.steps.len(), 1);
let step = loaded.steps.get("main/deploy").unwrap();
assert_eq!(step.name, "deploy");
assert_eq!(step.kind, StepKind::Exec);
assert_eq!(step.status, StepStatusPersisted::Completed);
}
}