use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrchestratorState {
pub iteration: u32,
pub reject_cycles: HashMap<String, u32>,
pub story_iterations: HashMap<String, u32>,
pub story_errors: HashMap<String, String>,
}
impl OrchestratorState {
fn path(project_root: &Path) -> PathBuf {
project_root.join(".regista/state.toml")
}
pub fn save(&self, project_root: &Path) -> anyhow::Result<()> {
let path = Self::path(project_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;
tracing::debug!("💾 checkpoint guardado (iteración {})", self.iteration);
Ok(())
}
pub fn load(project_root: &Path) -> Option<Self> {
let path = Self::path(project_root);
if !path.exists() {
return None;
}
match std::fs::read_to_string(&path) {
Ok(content) => match toml::from_str::<OrchestratorState>(&content) {
Ok(state) => {
tracing::info!(
"📂 checkpoint cargado: iteración {}, {} ciclos de rechazo acumulados",
state.iteration,
state.reject_cycles.len()
);
Some(state)
}
Err(e) => {
tracing::warn!("⚠️ checkpoint corrupto (parse error): {e}. Se ignorará.");
let _ = std::fs::remove_file(&path);
None
}
},
Err(e) => {
tracing::warn!("⚠️ no se pudo leer el checkpoint: {e}. Se ignorará.");
None
}
}
}
pub fn remove(project_root: &Path) {
let path = Self::path(project_root);
if path.exists() {
let _ = std::fs::remove_file(&path);
tracing::debug!("🧹 checkpoint eliminado");
}
}
#[allow(dead_code)]
pub fn fresh() -> Self {
Self {
iteration: 0,
reject_cycles: HashMap::new(),
story_iterations: HashMap::new(),
story_errors: HashMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_save_and_load_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let mut state = OrchestratorState::fresh();
state.iteration = 7;
state.reject_cycles.insert("STORY-001".into(), 2);
state.story_iterations.insert("STORY-001".into(), 3);
state
.story_errors
.insert("STORY-002".into(), "timeout".into());
state.save(tmp.path()).unwrap();
let loaded = OrchestratorState::load(tmp.path()).unwrap();
assert_eq!(loaded.iteration, 7);
assert_eq!(loaded.reject_cycles.get("STORY-001"), Some(&2));
assert_eq!(loaded.story_iterations.get("STORY-001"), Some(&3));
assert_eq!(
loaded.story_errors.get("STORY-002"),
Some(&"timeout".to_string())
);
}
#[test]
fn load_returns_none_when_no_file() {
let tmp = tempfile::tempdir().unwrap();
assert!(OrchestratorState::load(tmp.path()).is_none());
}
#[test]
fn load_returns_none_when_corrupt() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".regista")).unwrap();
std::fs::write(tmp.path().join(".regista/state.toml"), "esto no es toml{{{").unwrap();
assert!(OrchestratorState::load(tmp.path()).is_none());
assert!(!tmp.path().join(".regista/state.toml").exists());
}
#[test]
fn remove_cleans_file() {
let tmp = tempfile::tempdir().unwrap();
let state = OrchestratorState::fresh();
state.save(tmp.path()).unwrap();
assert!(tmp.path().join(".regista/state.toml").exists());
OrchestratorState::remove(tmp.path());
assert!(!tmp.path().join(".regista/state.toml").exists());
}
#[test]
fn fresh_state_is_empty() {
let state = OrchestratorState::fresh();
assert_eq!(state.iteration, 0);
assert!(state.reject_cycles.is_empty());
assert!(state.story_iterations.is_empty());
assert!(state.story_errors.is_empty());
}
}