ralph_workflow/files/
recovery.rs1use std::path::Path;
8
9use crate::workspace::Workspace;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RecoveryStatus {
14 Valid,
16 Recovered,
18 Unrecoverable(String),
20}
21
22#[derive(Debug, Clone)]
23struct StateValidation {
24 pub is_valid: bool,
25 pub issues: Vec<String>,
26}
27
28fn validate_agent_state_with_workspace(
33 workspace: &dyn Workspace,
34 agent_dir: &Path,
35) -> StateValidation {
36 let issues: Vec<String> = if !workspace.exists(agent_dir) {
37 vec![".agent/ directory does not exist".to_string()]
38 } else {
39 let unreadable_files: Vec<String> = workspace
40 .read_dir(agent_dir)
41 .ok()
42 .map(|entries| {
43 entries
44 .iter()
45 .filter_map(|entry| {
46 let path = entry.path();
47 if entry.is_file() && workspace.read(path).is_err() {
48 Some(format!("Corrupted file: {}", path.display()))
49 } else {
50 None
51 }
52 })
53 .collect()
54 })
55 .unwrap_or_default();
56
57 let zero_length_files: Vec<String> = [
58 "PLAN.md",
59 "ISSUES.md",
60 "STATUS.md",
61 "NOTES.md",
62 "commit-message.txt",
63 ]
64 .iter()
65 .filter_map(|filename| {
66 let file_path = agent_dir.join(filename);
67 let content = workspace.read(&file_path).ok();
68 if content.is_some_and(|c| c.is_empty()) {
69 Some(format!("Zero-length file: {filename}"))
70 } else {
71 None
72 }
73 })
74 .collect();
75
76 unreadable_files
77 .into_iter()
78 .chain(zero_length_files)
79 .collect()
80 };
81
82 StateValidation {
83 is_valid: issues.is_empty(),
84 issues,
85 }
86}
87
88fn remove_zero_length_files_with_workspace(
89 workspace: &dyn Workspace,
90 agent_dir: &Path,
91) -> std::io::Result<usize> {
92 let filenames = [
93 "PLAN.md",
94 "ISSUES.md",
95 "STATUS.md",
96 "NOTES.md",
97 "commit-message.txt",
98 ];
99
100 let removed: usize = filenames
101 .iter()
102 .filter_map(|filename| {
103 let file_path = agent_dir.join(filename);
104 if !workspace.exists(&file_path) {
105 return None;
106 }
107 if let Ok(content) = workspace.read(&file_path) {
108 if content.is_empty() {
109 workspace.remove(&file_path).ok()?;
110 return Some(1);
111 }
112 }
113 None
114 })
115 .sum();
116
117 Ok(removed)
118}
119
120pub fn auto_repair_with_workspace(
128 workspace: &dyn Workspace,
129 agent_dir: &Path,
130) -> std::io::Result<RecoveryStatus> {
131 if !workspace.exists(agent_dir) {
132 workspace.create_dir_all(&agent_dir.join("logs"))?;
133 return Ok(RecoveryStatus::Recovered);
134 }
135
136 let validation = validate_agent_state_with_workspace(workspace, agent_dir);
137 if validation.is_valid {
138 workspace.create_dir_all(&agent_dir.join("logs"))?;
139 return Ok(RecoveryStatus::Valid);
140 }
141
142 remove_zero_length_files_with_workspace(workspace, agent_dir)?;
144 workspace.create_dir_all(&agent_dir.join("logs"))?;
145
146 let post_validation = validate_agent_state_with_workspace(workspace, agent_dir);
147 if post_validation.is_valid {
148 Ok(RecoveryStatus::Recovered)
149 } else {
150 Ok(RecoveryStatus::Unrecoverable(format!(
151 "Unresolved .agent issues: {}",
152 post_validation.issues.join(", ")
153 )))
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::workspace::MemoryWorkspace;
161 use std::path::Path;
162
163 #[test]
164 fn auto_repair_with_workspace_creates_missing_directory() {
165 let workspace = MemoryWorkspace::new_test();
166 let agent_dir = Path::new(".agent");
167
168 let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
169
170 assert_eq!(status, RecoveryStatus::Recovered);
171 assert!(workspace.exists(&agent_dir.join("logs")));
172 }
173
174 #[test]
175 fn auto_repair_with_workspace_removes_zero_length_files() {
176 let workspace = MemoryWorkspace::new_test()
177 .with_file(".agent/logs/.keep", "")
178 .with_file(".agent/PLAN.md", ""); let agent_dir = Path::new(".agent");
181 let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
182
183 assert_eq!(status, RecoveryStatus::Recovered);
184 assert!(!workspace.exists(&agent_dir.join("PLAN.md")));
185 }
186
187 #[test]
188 fn auto_repair_with_workspace_valid_state() {
189 let workspace = MemoryWorkspace::new_test()
190 .with_file(".agent/logs/.keep", "")
191 .with_file(".agent/PLAN.md", "# Plan\nSome content");
192
193 let agent_dir = Path::new(".agent");
194 let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
195
196 assert_eq!(status, RecoveryStatus::Valid);
197 assert!(workspace.exists(&agent_dir.join("PLAN.md")));
198 }
199
200 #[test]
201 fn auto_repair_with_workspace_multiple_zero_length_files() {
202 let workspace = MemoryWorkspace::new_test()
203 .with_file(".agent/logs/.keep", "")
204 .with_file(".agent/PLAN.md", "")
205 .with_file(".agent/ISSUES.md", "")
206 .with_file(".agent/STATUS.md", "valid content");
207
208 let agent_dir = Path::new(".agent");
209 let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
210
211 assert_eq!(status, RecoveryStatus::Recovered);
212 assert!(!workspace.exists(&agent_dir.join("PLAN.md")));
213 assert!(!workspace.exists(&agent_dir.join("ISSUES.md")));
214 assert!(workspace.exists(&agent_dir.join("STATUS.md")));
215 }
216}