Skip to main content

ito_core/ralph/
state.rs

1//! Persistent Ralph loop state.
2//!
3//! The Ralph loop stores a small amount of JSON state on disk so users can:
4//! - inspect iteration history (duration, whether completion was detected)
5//! - add or clear additional context that is appended to future prompts
6//!
7//! State is stored under `.ito/.state/ralph/<change-id>/`.
8
9use 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")]
15/// One historical record for a Ralph iteration.
16pub struct RalphHistoryEntry {
17    /// Wall clock time (ms since epoch) when the iteration finished.
18    pub timestamp: i64,
19    /// Duration (ms) the harness run took.
20    pub duration: i64,
21    /// Whether the completion promise token was observed in harness stdout.
22    pub completion_promise_found: bool,
23    /// Number of changed files in the git working tree after the iteration.
24    pub file_changes_count: u32,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "camelCase")]
29/// Saved state for a Ralph loop scoped to a specific change.
30pub struct RalphState {
31    /// Change id this state belongs to.
32    pub change_id: String,
33    /// Last completed iteration number.
34    pub iteration: u32,
35    /// History entries for completed iterations.
36    pub history: Vec<RalphHistoryEntry>,
37    /// Display path for the context file (used by some UIs).
38    pub context_file: String,
39}
40
41/// Return the on-disk directory for Ralph state for `change_id`.
42pub 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
52/// Return the path to `state.json` for `change_id`.
53pub 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
57/// Return the path to `context.md` for `change_id`.
58pub fn ralph_context_path(ito_path: &Path, change_id: &str) -> PathBuf {
59    ralph_state_dir(ito_path, change_id).join("context.md")
60}
61
62/// Load saved state for `change_id`.
63pub 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
75/// Persist `state` for `change_id`.
76pub 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
88/// Load the saved context markdown for `change_id`.
89///
90/// Missing files return an empty string.
91pub 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
100/// Append `text` to the saved context for `change_id`.
101///
102/// Empty/whitespace-only input is ignored.
103pub 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
130/// Clear the saved context for `change_id`.
131pub 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}