use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::plan::PhaseId;
use crate::util::{paths, write_atomic};
pub fn state_path(workspace: impl AsRef<Path>) -> PathBuf {
paths::state_path(workspace)
}
pub fn load(workspace: impl AsRef<Path>) -> Result<Option<RunState>> {
let path = state_path(&workspace);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(anyhow::Error::new(e).context(format!("state::load: reading {:?}", path)))
}
};
if bytes.iter().all(u8::is_ascii_whitespace) {
return Ok(None);
}
let parsed: Option<RunState> = serde_json::from_slice(&bytes)
.with_context(|| format!("state::load: parsing {:?}", path))?;
Ok(parsed)
}
pub fn save(workspace: impl AsRef<Path>, state: Option<&RunState>) -> Result<()> {
let path = state_path(&workspace);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("state::save: creating {:?}", parent))?;
}
let mut bytes = serde_json::to_vec_pretty(&state)
.with_context(|| format!("state::save: serializing {:?}", path))?;
bytes.push(b'\n');
write_atomic(&path, &bytes)?;
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleUsage {
pub input: u64,
pub output: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub input: u64,
pub output: u64,
pub by_role: HashMap<String, RoleUsage>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunState {
pub run_id: String,
pub branch: String,
#[serde(default)]
pub original_branch: Option<String>,
pub started_at: DateTime<Utc>,
pub started_phase: PhaseId,
pub completed: Vec<PhaseId>,
pub attempts: HashMap<PhaseId, u32>,
pub token_usage: TokenUsage,
#[serde(default)]
pub aborted: bool,
#[serde(default)]
pub pending_sweep: bool,
#[serde(default)]
pub consecutive_sweeps: u32,
#[serde(default)]
pub deferred_item_attempts: HashMap<String, u32>,
#[serde(default)]
pub post_final_phase: bool,
}
impl RunState {
pub fn new(
run_id: impl Into<String>,
branch: impl Into<String>,
started_phase: PhaseId,
) -> Self {
Self {
run_id: run_id.into(),
branch: branch.into(),
original_branch: None,
started_at: Utc::now(),
started_phase,
completed: Vec::new(),
attempts: HashMap::new(),
token_usage: TokenUsage::default(),
aborted: false,
pending_sweep: false,
consecutive_sweeps: 0,
deferred_item_attempts: HashMap::new(),
post_final_phase: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pid(s: &str) -> PhaseId {
PhaseId::parse(s).unwrap()
}
#[test]
fn round_trips_through_json() {
let mut by_role = HashMap::new();
by_role.insert(
"implementer".to_string(),
RoleUsage {
input: 1234,
output: 567,
},
);
by_role.insert(
"auditor".to_string(),
RoleUsage {
input: 200,
output: 50,
},
);
let mut attempts = HashMap::new();
attempts.insert(pid("02"), 1);
attempts.insert(pid("10b"), 3);
let mut deferred_item_attempts = HashMap::new();
deferred_item_attempts.insert("polish error message".to_string(), 2);
deferred_item_attempts.insert("drop unused stub".to_string(), 1);
let state = RunState {
run_id: "20260429T143022Z".into(),
branch: "pitboss/run-20260429T143022Z".into(),
original_branch: Some("main".into()),
started_at: DateTime::parse_from_rfc3339("2026-04-29T14:30:22Z")
.unwrap()
.with_timezone(&Utc),
started_phase: pid("02"),
completed: vec![pid("01")],
attempts,
token_usage: TokenUsage {
input: 1434,
output: 617,
by_role,
},
aborted: false,
pending_sweep: false,
consecutive_sweeps: 0,
deferred_item_attempts,
post_final_phase: false,
};
let json = serde_json::to_string(&state).unwrap();
let back: RunState = serde_json::from_str(&json).unwrap();
assert_eq!(state, back);
}
#[test]
fn deserializes_legacy_state_without_new_fields() {
let legacy = serde_json::json!({
"run_id": "rid",
"branch": "br",
"started_at": "2026-04-29T14:30:22Z",
"started_phase": "01",
"completed": [],
"attempts": {},
"token_usage": {"input": 0, "output": 0, "by_role": {}}
});
let state: RunState = serde_json::from_value(legacy).unwrap();
assert_eq!(state.original_branch, None);
assert!(!state.aborted);
assert!(!state.pending_sweep);
assert_eq!(state.consecutive_sweeps, 0);
assert!(state.deferred_item_attempts.is_empty());
}
#[test]
fn deserializes_phase_04_state_without_deferred_item_attempts() {
let phase_04 = serde_json::json!({
"run_id": "20260430T120000Z",
"branch": "pitboss/play/20260430T120000Z",
"original_branch": "main",
"started_at": "2026-04-30T12:00:00Z",
"started_phase": "01",
"completed": ["01"],
"attempts": {"01": 2},
"token_usage": {"input": 100, "output": 50, "by_role": {}},
"aborted": false,
"pending_sweep": true,
"consecutive_sweeps": 1
});
let state: RunState = serde_json::from_value(phase_04).unwrap();
assert!(state.deferred_item_attempts.is_empty());
assert!(state.pending_sweep);
assert_eq!(state.consecutive_sweeps, 1);
}
#[test]
fn new_initializes_empty_aggregates() {
let s = RunState::new("rid", "branch", pid("01"));
assert_eq!(s.run_id, "rid");
assert_eq!(s.branch, "branch");
assert!(s.completed.is_empty());
assert!(s.attempts.is_empty());
assert_eq!(s.token_usage.input, 0);
assert_eq!(s.token_usage.output, 0);
assert!(s.token_usage.by_role.is_empty());
assert!(s.deferred_item_attempts.is_empty());
}
#[test]
fn phase_id_is_usable_as_map_key_through_serde() {
let mut attempts = HashMap::new();
attempts.insert(pid("01"), 2);
let json = serde_json::to_string(&attempts).unwrap();
let back: HashMap<PhaseId, u32> = serde_json::from_str(&json).unwrap();
assert_eq!(back.get(&pid("01")), Some(&2));
}
#[test]
fn load_returns_none_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(load(dir.path()).unwrap().is_none());
}
#[test]
fn save_none_then_load_round_trips_to_none() {
let dir = tempfile::tempdir().unwrap();
save(dir.path(), None).unwrap();
let path = state_path(dir.path());
assert!(path.exists(), "state.json should be created by save()");
let contents = std::fs::read_to_string(&path).unwrap();
assert!(
contents.trim_end() == "null",
"expected JSON null, got {:?}",
contents
);
assert!(load(dir.path()).unwrap().is_none());
}
#[test]
fn save_some_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let state = RunState::new("rid", "branch", pid("02"));
save(dir.path(), Some(&state)).unwrap();
let loaded = load(dir.path()).unwrap().expect("expected Some(RunState)");
assert_eq!(loaded, state);
}
#[test]
fn load_returns_none_for_whitespace_only_file() {
let dir = tempfile::tempdir().unwrap();
let path = state_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, " \n\t\n").unwrap();
assert!(load(dir.path()).unwrap().is_none());
}
#[test]
fn load_surfaces_parse_error_for_garbage() {
let dir = tempfile::tempdir().unwrap();
let path = state_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{ not valid json").unwrap();
let err = load(dir.path()).unwrap_err();
assert!(err.to_string().contains("state::load: parsing"));
}
}