1use crate::errors::{CoreError, CoreResult};
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "camelCase")]
15pub struct RalphHistoryEntry {
17 pub timestamp: i64,
19 pub duration: i64,
21 pub completion_promise_found: bool,
23 pub file_changes_count: u32,
25 #[serde(default)]
27 pub harness_exit_code: i32,
28 #[serde(default)]
30 pub completion_validated: bool,
31 #[serde(default)]
33 pub effective_cwd: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37#[serde(rename_all = "camelCase")]
38pub struct RalphState {
40 pub change_id: String,
42 pub iteration: u32,
44 pub history: Vec<RalphHistoryEntry>,
46 pub context_file: String,
48 #[serde(default)]
50 pub last_outcome: Option<String>,
51 #[serde(default)]
53 pub last_failure: Option<String>,
54}
55
56pub fn ralph_state_dir(ito_path: &Path, change_id: &str) -> PathBuf {
58 if !is_safe_change_id_segment(change_id) {
59 return ito_path
60 .join(".state")
61 .join("ralph")
62 .join("invalid-change-id");
63 }
64 ito_path.join(".state").join("ralph").join(change_id)
65}
66
67pub fn ralph_state_json_path(ito_path: &Path, change_id: &str) -> PathBuf {
69 ralph_state_dir(ito_path, change_id).join("state.json")
70}
71
72pub fn ralph_context_path(ito_path: &Path, change_id: &str) -> PathBuf {
74 ralph_state_dir(ito_path, change_id).join("context.md")
75}
76
77pub fn load_state(ito_path: &Path, change_id: &str) -> CoreResult<Option<RalphState>> {
79 let p = ralph_state_json_path(ito_path, change_id);
80 if !p.exists() {
81 return Ok(None);
82 }
83 let raw = ito_common::io::read_to_string_std(&p)
84 .map_err(|e| CoreError::io(format!("reading {}", p.display()), e))?;
85 let state = serde_json::from_str(&raw)
86 .map_err(|e| CoreError::Parse(format!("JSON error parsing {p}: {e}", p = p.display())))?;
87 Ok(Some(state))
88}
89
90pub fn save_state(ito_path: &Path, change_id: &str, state: &RalphState) -> CoreResult<()> {
92 let dir = ralph_state_dir(ito_path, change_id);
93 ito_common::io::create_dir_all_std(&dir)
94 .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
95 let p = ralph_state_json_path(ito_path, change_id);
96 let raw = serde_json::to_string_pretty(state)
97 .map_err(|e| CoreError::Parse(format!("JSON error serializing state: {e}")))?;
98 ito_common::io::write_std(&p, raw)
99 .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
100 Ok(())
101}
102
103pub fn load_context(ito_path: &Path, change_id: &str) -> CoreResult<String> {
107 let p = ralph_context_path(ito_path, change_id);
108 if !p.exists() {
109 return Ok(String::new());
110 }
111 ito_common::io::read_to_string_std(&p)
112 .map_err(|e| CoreError::io(format!("reading {}", p.display()), e))
113}
114
115pub fn append_context(ito_path: &Path, change_id: &str, text: &str) -> CoreResult<()> {
119 let dir = ralph_state_dir(ito_path, change_id);
120 ito_common::io::create_dir_all_std(&dir)
121 .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
122 let p = ralph_context_path(ito_path, change_id);
123 let existing_result = ito_common::io::read_to_string_std(&p);
124 let mut existing = match existing_result {
125 Ok(s) => s,
126 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
127 Err(e) => return Err(CoreError::io(format!("reading {}", p.display()), e)),
128 };
129
130 let trimmed = text.trim();
131 if trimmed.is_empty() {
132 return Ok(());
133 }
134
135 if !existing.trim().is_empty() {
136 existing.push_str("\n\n");
137 }
138 existing.push_str(trimmed);
139 existing.push('\n');
140 ito_common::io::write_std(&p, existing)
141 .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
142 Ok(())
143}
144
145pub fn clear_context(ito_path: &Path, change_id: &str) -> CoreResult<()> {
147 let dir = ralph_state_dir(ito_path, change_id);
148 ito_common::io::create_dir_all_std(&dir)
149 .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
150 let p = ralph_context_path(ito_path, change_id);
151 ito_common::io::write_std(&p, "")
152 .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
153 Ok(())
154}
155
156fn is_safe_change_id_segment(change_id: &str) -> bool {
157 let change_id = change_id.trim();
158 if change_id.is_empty() {
159 return false;
160 }
161 if change_id.len() > 256 {
162 return false;
163 }
164 if change_id.contains('/') || change_id.contains('\\') || change_id.contains("..") {
165 return false;
166 }
167 true
168}
169
170#[cfg(test)]
171#[path = "state_tests.rs"]
172mod state_tests;