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}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "camelCase")]
29pub struct RalphState {
31 pub change_id: String,
33 pub iteration: u32,
35 pub history: Vec<RalphHistoryEntry>,
37 pub context_file: String,
39}
40
41pub fn ralph_state_dir(ito_path: &Path, change_id: &str) -> PathBuf {
43 if !is_safe_change_id_segment(change_id) {
44 return ito_path
45 .join(".state")
46 .join("ralph")
47 .join("invalid-change-id");
48 }
49 ito_path.join(".state").join("ralph").join(change_id)
50}
51
52pub fn ralph_state_json_path(ito_path: &Path, change_id: &str) -> PathBuf {
54 ralph_state_dir(ito_path, change_id).join("state.json")
55}
56
57pub fn ralph_context_path(ito_path: &Path, change_id: &str) -> PathBuf {
59 ralph_state_dir(ito_path, change_id).join("context.md")
60}
61
62pub fn load_state(ito_path: &Path, change_id: &str) -> CoreResult<Option<RalphState>> {
64 let p = ralph_state_json_path(ito_path, change_id);
65 if !p.exists() {
66 return Ok(None);
67 }
68 let raw = ito_common::io::read_to_string_std(&p)
69 .map_err(|e| CoreError::io(format!("reading {}", p.display()), e))?;
70 let state = serde_json::from_str(&raw)
71 .map_err(|e| CoreError::Parse(format!("JSON error parsing {p}: {e}", p = p.display())))?;
72 Ok(Some(state))
73}
74
75pub fn save_state(ito_path: &Path, change_id: &str, state: &RalphState) -> CoreResult<()> {
77 let dir = ralph_state_dir(ito_path, change_id);
78 ito_common::io::create_dir_all_std(&dir)
79 .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
80 let p = ralph_state_json_path(ito_path, change_id);
81 let raw = serde_json::to_string_pretty(state)
82 .map_err(|e| CoreError::Parse(format!("JSON error serializing state: {e}")))?;
83 ito_common::io::write_std(&p, raw)
84 .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
85 Ok(())
86}
87
88pub fn load_context(ito_path: &Path, change_id: &str) -> CoreResult<String> {
92 let p = ralph_context_path(ito_path, change_id);
93 if !p.exists() {
94 return Ok(String::new());
95 }
96 ito_common::io::read_to_string_std(&p)
97 .map_err(|e| CoreError::io(format!("reading {}", p.display()), e))
98}
99
100pub fn append_context(ito_path: &Path, change_id: &str, text: &str) -> CoreResult<()> {
104 let dir = ralph_state_dir(ito_path, change_id);
105 ito_common::io::create_dir_all_std(&dir)
106 .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
107 let p = ralph_context_path(ito_path, change_id);
108 let existing_result = ito_common::io::read_to_string_std(&p);
109 let mut existing = match existing_result {
110 Ok(s) => s,
111 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
112 Err(e) => return Err(CoreError::io(format!("reading {}", p.display()), e)),
113 };
114
115 let trimmed = text.trim();
116 if trimmed.is_empty() {
117 return Ok(());
118 }
119
120 if !existing.trim().is_empty() {
121 existing.push_str("\n\n");
122 }
123 existing.push_str(trimmed);
124 existing.push('\n');
125 ito_common::io::write_std(&p, existing)
126 .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
127 Ok(())
128}
129
130pub fn clear_context(ito_path: &Path, change_id: &str) -> CoreResult<()> {
132 let dir = ralph_state_dir(ito_path, change_id);
133 ito_common::io::create_dir_all_std(&dir)
134 .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
135 let p = ralph_context_path(ito_path, change_id);
136 ito_common::io::write_std(&p, "")
137 .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
138 Ok(())
139}
140
141fn is_safe_change_id_segment(change_id: &str) -> bool {
142 let change_id = change_id.trim();
143 if change_id.is_empty() {
144 return false;
145 }
146 if change_id.len() > 256 {
147 return false;
148 }
149 if change_id.contains('/') || change_id.contains('\\') || change_id.contains("..") {
150 return false;
151 }
152 true
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn ralph_state_dir_uses_safe_fallback_for_invalid_change_ids() {
161 let ito = std::path::Path::new("/tmp/repo/.ito");
162 let path = ralph_state_dir(ito, "../escape");
163 assert!(path.ends_with(".state/ralph/invalid-change-id"));
164 }
165
166 #[test]
167 fn save_and_load_state_round_trip() {
168 let td = tempfile::tempdir().unwrap();
169 let ito = td.path().join(".ito");
170 let change_id = "001-01_test";
171 let state = RalphState {
172 change_id: change_id.to_string(),
173 iteration: 5,
174 history: vec![RalphHistoryEntry {
175 timestamp: 1234567890,
176 duration: 5000,
177 completion_promise_found: true,
178 file_changes_count: 3,
179 }],
180 context_file: ".ito/.state/ralph/001-01_test/context.md".to_string(),
181 };
182 save_state(&ito, change_id, &state).unwrap();
183 let loaded = load_state(&ito, change_id).unwrap();
184 assert!(loaded.is_some());
185 let loaded = loaded.unwrap();
186 assert_eq!(loaded.change_id, state.change_id);
187 assert_eq!(loaded.iteration, state.iteration);
188 assert_eq!(loaded.history.len(), state.history.len());
189 assert_eq!(loaded.history[0].timestamp, state.history[0].timestamp);
190 assert_eq!(loaded.history[0].duration, state.history[0].duration);
191 assert_eq!(
192 loaded.history[0].completion_promise_found,
193 state.history[0].completion_promise_found
194 );
195 assert_eq!(
196 loaded.history[0].file_changes_count,
197 state.history[0].file_changes_count
198 );
199 assert_eq!(loaded.context_file, state.context_file);
200 }
201
202 #[test]
203 fn load_state_returns_none_when_missing() {
204 let td = tempfile::tempdir().unwrap();
205 let ito = td.path().join(".ito");
206 let result = load_state(&ito, "nonexistent").unwrap();
207 assert!(result.is_none());
208 }
209
210 #[test]
211 fn is_safe_change_id_segment_rejects_empty() {
212 let ito = tempfile::tempdir().unwrap();
213 let ito_path = ito.path().join(".ito");
214 let path = ralph_state_dir(&ito_path, "");
215 assert!(path.ends_with(".state/ralph/invalid-change-id"));
216 }
217
218 #[test]
219 fn is_safe_change_id_segment_rejects_too_long() {
220 let ito = tempfile::tempdir().unwrap();
221 let ito_path = ito.path().join(".ito");
222 let long_id = "a".repeat(257);
223 let path = ralph_state_dir(&ito_path, &long_id);
224 assert!(path.ends_with(".state/ralph/invalid-change-id"));
225 }
226
227 #[test]
228 fn is_safe_change_id_segment_rejects_backslash() {
229 let ito = tempfile::tempdir().unwrap();
230 let ito_path = ito.path().join(".ito");
231 let path = ralph_state_dir(&ito_path, "foo\\bar");
232 assert!(path.ends_with(".state/ralph/invalid-change-id"));
233 }
234
235 #[test]
236 fn is_safe_change_id_segment_accepts_valid() {
237 let ito = tempfile::tempdir().unwrap();
238 let ito_path = ito.path().join(".ito");
239 let path = ralph_state_dir(&ito_path, "003-05_my-change");
240 assert!(path.ends_with("003-05_my-change"));
241 }
242
243 #[test]
244 fn append_context_no_op_on_whitespace() {
245 let td = tempfile::tempdir().unwrap();
246 let ito = td.path().join(".ito");
247 let change_id = "001-01_test";
248 append_context(&ito, change_id, " \n ").unwrap();
249 let context_path = ralph_context_path(&ito, change_id);
250 if context_path.exists() {
251 let content = ito_common::io::read_to_string_std(&context_path).unwrap();
252 assert!(content.is_empty());
253 }
254 }
255
256 #[test]
257 fn load_context_returns_empty_when_missing() {
258 let td = tempfile::tempdir().unwrap();
259 let ito = td.path().join(".ito");
260 let result = load_context(&ito, "nonexistent").unwrap();
261 assert_eq!(result, "");
262 }
263
264 #[test]
265 fn ralph_state_json_path_correct() {
266 let ito = std::path::Path::new("/tmp/repo/.ito");
267 let path = ralph_state_json_path(ito, "001-01_test");
268 assert!(path.ends_with("state.json"));
269 }
270
271 #[test]
272 fn ralph_context_path_correct() {
273 let ito = std::path::Path::new("/tmp/repo/.ito");
274 let path = ralph_context_path(ito, "001-01_test");
275 assert!(path.ends_with("context.md"));
276 }
277}