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)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn ralph_state_dir_uses_safe_fallback_for_invalid_change_ids() {
176 let ito = std::path::Path::new("/tmp/repo/.ito");
177 let path = ralph_state_dir(ito, "../escape");
178 assert!(path.ends_with(".state/ralph/invalid-change-id"));
179 }
180
181 #[test]
182 fn save_and_load_state_round_trip() {
183 let td = tempfile::tempdir().unwrap();
184 let ito = td.path().join(".ito");
185 let change_id = "001-01_test";
186 let state = RalphState {
187 change_id: change_id.to_string(),
188 iteration: 5,
189 history: vec![RalphHistoryEntry {
190 timestamp: 1234567890,
191 duration: 5000,
192 completion_promise_found: true,
193 file_changes_count: 3,
194 harness_exit_code: 0,
195 completion_validated: true,
196 effective_cwd: "/tmp/worktree".to_string(),
197 }],
198 context_file: ".ito/.state/ralph/001-01_test/context.md".to_string(),
199 last_outcome: Some("validated-complete".to_string()),
200 last_failure: None,
201 };
202 save_state(&ito, change_id, &state).unwrap();
203 let loaded = load_state(&ito, change_id).unwrap();
204 assert!(loaded.is_some());
205 let loaded = loaded.unwrap();
206 assert_eq!(loaded.change_id, state.change_id);
207 assert_eq!(loaded.iteration, state.iteration);
208 assert_eq!(loaded.history.len(), state.history.len());
209 assert_eq!(loaded.history[0].timestamp, state.history[0].timestamp);
210 assert_eq!(loaded.history[0].duration, state.history[0].duration);
211 assert_eq!(
212 loaded.history[0].completion_promise_found,
213 state.history[0].completion_promise_found
214 );
215 assert_eq!(
216 loaded.history[0].file_changes_count,
217 state.history[0].file_changes_count
218 );
219 assert_eq!(loaded.history[0].harness_exit_code, 0);
220 assert!(loaded.history[0].completion_validated);
221 assert_eq!(loaded.history[0].effective_cwd, "/tmp/worktree");
222 assert_eq!(loaded.context_file, state.context_file);
223 assert_eq!(loaded.last_outcome.as_deref(), Some("validated-complete"));
224 assert_eq!(loaded.last_failure, None);
225 }
226
227 #[test]
228 fn load_state_returns_none_when_missing() {
229 let td = tempfile::tempdir().unwrap();
230 let ito = td.path().join(".ito");
231 let result = load_state(&ito, "nonexistent").unwrap();
232 assert!(result.is_none());
233 }
234
235 #[test]
236 fn load_state_backfills_missing_new_fields() {
237 let td = tempfile::tempdir().unwrap();
238 let ito = td.path().join(".ito");
239 let change_id = "001-01_test";
240 let dir = ralph_state_dir(&ito, change_id);
241 std::fs::create_dir_all(&dir).unwrap();
242 let raw = r#"{
243 "changeId": "001-01_test",
244 "iteration": 2,
245 "history": [
246 {
247 "timestamp": 1,
248 "duration": 2,
249 "completionPromiseFound": true,
250 "fileChangesCount": 3
251 }
252 ],
253 "contextFile": ".ito/.state/ralph/001-01_test/context.md"
254}"#;
255 std::fs::write(ralph_state_json_path(&ito, change_id), raw).unwrap();
256
257 let loaded = load_state(&ito, change_id).unwrap().unwrap();
258 assert_eq!(loaded.history[0].harness_exit_code, 0);
259 assert!(!loaded.history[0].completion_validated);
260 assert_eq!(loaded.history[0].effective_cwd, "");
261 assert_eq!(loaded.last_outcome, None);
262 assert_eq!(loaded.last_failure, None);
263 }
264
265 #[test]
266 fn is_safe_change_id_segment_rejects_empty() {
267 let ito = tempfile::tempdir().unwrap();
268 let ito_path = ito.path().join(".ito");
269 let path = ralph_state_dir(&ito_path, "");
270 assert!(path.ends_with(".state/ralph/invalid-change-id"));
271 }
272
273 #[test]
274 fn is_safe_change_id_segment_rejects_too_long() {
275 let ito = tempfile::tempdir().unwrap();
276 let ito_path = ito.path().join(".ito");
277 let long_id = "a".repeat(257);
278 let path = ralph_state_dir(&ito_path, &long_id);
279 assert!(path.ends_with(".state/ralph/invalid-change-id"));
280 }
281
282 #[test]
283 fn is_safe_change_id_segment_rejects_backslash() {
284 let ito = tempfile::tempdir().unwrap();
285 let ito_path = ito.path().join(".ito");
286 let path = ralph_state_dir(&ito_path, "foo\\bar");
287 assert!(path.ends_with(".state/ralph/invalid-change-id"));
288 }
289
290 #[test]
291 fn is_safe_change_id_segment_accepts_valid() {
292 let ito = tempfile::tempdir().unwrap();
293 let ito_path = ito.path().join(".ito");
294 let path = ralph_state_dir(&ito_path, "003-05_my-change");
295 assert!(path.ends_with("003-05_my-change"));
296 }
297
298 #[test]
299 fn append_context_no_op_on_whitespace() {
300 let td = tempfile::tempdir().unwrap();
301 let ito = td.path().join(".ito");
302 let change_id = "001-01_test";
303 append_context(&ito, change_id, " \n ").unwrap();
304 let context_path = ralph_context_path(&ito, change_id);
305 if context_path.exists() {
306 let content = ito_common::io::read_to_string_std(&context_path).unwrap();
307 assert!(content.is_empty());
308 }
309 }
310
311 #[test]
312 fn load_context_returns_empty_when_missing() {
313 let td = tempfile::tempdir().unwrap();
314 let ito = td.path().join(".ito");
315 let result = load_context(&ito, "nonexistent").unwrap();
316 assert_eq!(result, "");
317 }
318
319 #[test]
320 fn ralph_state_json_path_correct() {
321 let ito = std::path::Path::new("/tmp/repo/.ito");
322 let path = ralph_state_json_path(ito, "001-01_test");
323 assert!(path.ends_with("state.json"));
324 }
325
326 #[test]
327 fn ralph_context_path_correct() {
328 let ito = std::path::Path::new("/tmp/repo/.ito");
329 let path = ralph_context_path(ito, "001-01_test");
330 assert!(path.ends_with("context.md"));
331 }
332}