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}