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}