use crate::errors::{CoreError, CoreResult};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RalphHistoryEntry {
pub timestamp: i64,
pub duration: i64,
pub completion_promise_found: bool,
pub file_changes_count: u32,
#[serde(default)]
pub harness_exit_code: i32,
#[serde(default)]
pub completion_validated: bool,
#[serde(default)]
pub effective_cwd: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RalphState {
pub change_id: String,
pub iteration: u32,
pub history: Vec<RalphHistoryEntry>,
pub context_file: String,
#[serde(default)]
pub last_outcome: Option<String>,
#[serde(default)]
pub last_failure: Option<String>,
}
pub fn ralph_state_dir(ito_path: &Path, change_id: &str) -> PathBuf {
if !is_safe_change_id_segment(change_id) {
return ito_path
.join(".state")
.join("ralph")
.join("invalid-change-id");
}
ito_path.join(".state").join("ralph").join(change_id)
}
pub fn ralph_state_json_path(ito_path: &Path, change_id: &str) -> PathBuf {
ralph_state_dir(ito_path, change_id).join("state.json")
}
pub fn ralph_context_path(ito_path: &Path, change_id: &str) -> PathBuf {
ralph_state_dir(ito_path, change_id).join("context.md")
}
pub fn load_state(ito_path: &Path, change_id: &str) -> CoreResult<Option<RalphState>> {
let p = ralph_state_json_path(ito_path, change_id);
if !p.exists() {
return Ok(None);
}
let raw = ito_common::io::read_to_string_std(&p)
.map_err(|e| CoreError::io(format!("reading {}", p.display()), e))?;
let state = serde_json::from_str(&raw)
.map_err(|e| CoreError::Parse(format!("JSON error parsing {p}: {e}", p = p.display())))?;
Ok(Some(state))
}
pub fn save_state(ito_path: &Path, change_id: &str, state: &RalphState) -> CoreResult<()> {
let dir = ralph_state_dir(ito_path, change_id);
ito_common::io::create_dir_all_std(&dir)
.map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
let p = ralph_state_json_path(ito_path, change_id);
let raw = serde_json::to_string_pretty(state)
.map_err(|e| CoreError::Parse(format!("JSON error serializing state: {e}")))?;
ito_common::io::write_std(&p, raw)
.map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
Ok(())
}
pub fn load_context(ito_path: &Path, change_id: &str) -> CoreResult<String> {
let p = ralph_context_path(ito_path, change_id);
if !p.exists() {
return Ok(String::new());
}
ito_common::io::read_to_string_std(&p)
.map_err(|e| CoreError::io(format!("reading {}", p.display()), e))
}
pub fn append_context(ito_path: &Path, change_id: &str, text: &str) -> CoreResult<()> {
let dir = ralph_state_dir(ito_path, change_id);
ito_common::io::create_dir_all_std(&dir)
.map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
let p = ralph_context_path(ito_path, change_id);
let existing_result = ito_common::io::read_to_string_std(&p);
let mut existing = match existing_result {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(CoreError::io(format!("reading {}", p.display()), e)),
};
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(());
}
if !existing.trim().is_empty() {
existing.push_str("\n\n");
}
existing.push_str(trimmed);
existing.push('\n');
ito_common::io::write_std(&p, existing)
.map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
Ok(())
}
pub fn clear_context(ito_path: &Path, change_id: &str) -> CoreResult<()> {
let dir = ralph_state_dir(ito_path, change_id);
ito_common::io::create_dir_all_std(&dir)
.map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
let p = ralph_context_path(ito_path, change_id);
ito_common::io::write_std(&p, "")
.map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
Ok(())
}
fn is_safe_change_id_segment(change_id: &str) -> bool {
let change_id = change_id.trim();
if change_id.is_empty() {
return false;
}
if change_id.len() > 256 {
return false;
}
if change_id.contains('/') || change_id.contains('\\') || change_id.contains("..") {
return false;
}
true
}
#[cfg(test)]
#[path = "state_tests.rs"]
mod state_tests;