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    ito_path.join(".state").join("ralph").join(change_id)
44}
45
46/// Return the path to `state.json` for `change_id`.
47pub fn ralph_state_json_path(ito_path: &Path, change_id: &str) -> PathBuf {
48    ralph_state_dir(ito_path, change_id).join("state.json")
49}
50
51/// Return the path to `context.md` for `change_id`.
52pub fn ralph_context_path(ito_path: &Path, change_id: &str) -> PathBuf {
53    ralph_state_dir(ito_path, change_id).join("context.md")
54}
55
56/// Load saved state for `change_id`.
57pub fn load_state(ito_path: &Path, change_id: &str) -> CoreResult<Option<RalphState>> {
58    let p = ralph_state_json_path(ito_path, change_id);
59    if !p.exists() {
60        return Ok(None);
61    }
62    let raw = ito_common::io::read_to_string_std(&p)
63        .map_err(|e| CoreError::io(format!("reading {}", p.display()), e))?;
64    let state = serde_json::from_str(&raw)
65        .map_err(|e| CoreError::Parse(format!("JSON error parsing {p}: {e}", p = p.display())))?;
66    Ok(Some(state))
67}
68
69/// Persist `state` for `change_id`.
70pub fn save_state(ito_path: &Path, change_id: &str, state: &RalphState) -> CoreResult<()> {
71    let dir = ralph_state_dir(ito_path, change_id);
72    ito_common::io::create_dir_all_std(&dir)
73        .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
74    let p = ralph_state_json_path(ito_path, change_id);
75    let raw = serde_json::to_string_pretty(state)
76        .map_err(|e| CoreError::Parse(format!("JSON error serializing state: {e}")))?;
77    ito_common::io::write_std(&p, raw)
78        .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
79    Ok(())
80}
81
82/// Load the saved context markdown for `change_id`.
83///
84/// Missing files return an empty string.
85pub fn load_context(ito_path: &Path, change_id: &str) -> CoreResult<String> {
86    let p = ralph_context_path(ito_path, change_id);
87    if !p.exists() {
88        return Ok(String::new());
89    }
90    ito_common::io::read_to_string_std(&p)
91        .map_err(|e| CoreError::io(format!("reading {}", p.display()), e))
92}
93
94/// Append `text` to the saved context for `change_id`.
95///
96/// Empty/whitespace-only input is ignored.
97pub fn append_context(ito_path: &Path, change_id: &str, text: &str) -> CoreResult<()> {
98    let dir = ralph_state_dir(ito_path, change_id);
99    ito_common::io::create_dir_all_std(&dir)
100        .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
101    let p = ralph_context_path(ito_path, change_id);
102    let existing_result = ito_common::io::read_to_string_std(&p);
103    let mut existing = match existing_result {
104        Ok(s) => s,
105        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
106        Err(e) => return Err(CoreError::io(format!("reading {}", p.display()), e)),
107    };
108
109    let trimmed = text.trim();
110    if trimmed.is_empty() {
111        return Ok(());
112    }
113
114    if !existing.trim().is_empty() {
115        existing.push_str("\n\n");
116    }
117    existing.push_str(trimmed);
118    existing.push('\n');
119    ito_common::io::write_std(&p, existing)
120        .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
121    Ok(())
122}
123
124/// Clear the saved context for `change_id`.
125pub fn clear_context(ito_path: &Path, change_id: &str) -> CoreResult<()> {
126    let dir = ralph_state_dir(ito_path, change_id);
127    ito_common::io::create_dir_all_std(&dir)
128        .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
129    let p = ralph_context_path(ito_path, change_id);
130    ito_common::io::write_std(&p, "")
131        .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
132    Ok(())
133}