ralph_workflow/files/protection/validation/
helpers.rs1use crate::workspace::{Workspace, WorkspaceFs};
4#[cfg(test)]
5use std::fs;
6use std::io::IsTerminal;
7use std::path::Path;
8
9pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
10 if needle.is_empty() {
11 return true;
12 }
13 if needle.len() > haystack.len() {
14 return false;
15 }
16
17 let needle = needle.as_bytes();
18 for window in haystack.as_bytes().windows(needle.len()) {
19 if window
20 .iter()
21 .zip(needle.iter())
22 .all(|(a, b)| a.eq_ignore_ascii_case(b))
23 {
24 return true;
25 }
26 }
27 false
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum FileState {
33 Missing,
35 Empty,
37 Present,
39}
40
41#[derive(Debug, Clone)]
45pub struct PromptValidationResult {
49 pub file_state: FileState,
51 pub has_goal: bool,
53 pub has_acceptance: bool,
55 pub warnings: Vec<String>,
57 pub errors: Vec<String>,
59}
60
61impl PromptValidationResult {
62 pub const fn exists(&self) -> bool {
64 matches!(self.file_state, FileState::Present | FileState::Empty)
65 }
66
67 pub const fn has_content(&self) -> bool {
69 matches!(self.file_state, FileState::Present)
70 }
71}
72
73impl PromptValidationResult {
74 pub const fn is_valid(&self) -> bool {
76 self.errors.is_empty()
77 }
78
79 pub const fn is_perfect(&self) -> bool {
81 self.errors.is_empty() && self.warnings.is_empty()
82 }
83}
84
85#[cfg(test)]
106pub fn restore_prompt_if_needed() -> anyhow::Result<bool> {
107 let prompt_path = Path::new("PROMPT.md");
108
109 let prompt_ok = prompt_path
111 .exists()
112 .then(|| fs::read_to_string(prompt_path).ok())
113 .flatten()
114 .is_some_and(|s| !s.trim().is_empty());
115
116 if prompt_ok {
117 return Ok(true);
118 }
119
120 let backup_paths = [
122 Path::new(".agent/PROMPT.md.backup"),
123 Path::new(".agent/PROMPT.md.backup.1"),
124 Path::new(".agent/PROMPT.md.backup.2"),
125 ];
126
127 for backup_path in &backup_paths {
128 if backup_path.exists() {
129 let Ok(backup_content) = fs::read_to_string(backup_path) else {
131 continue;
132 };
133
134 if backup_content.trim().is_empty() {
135 continue; }
137
138 fs::write(prompt_path, backup_content)?;
140
141 #[cfg(unix)]
143 {
144 use std::os::unix::fs::PermissionsExt;
145 if let Ok(metadata) = fs::metadata(prompt_path) {
146 let mut perms = metadata.permissions();
147 perms.set_mode(0o444);
148 let _ = fs::set_permissions(prompt_path, perms);
149 }
150 }
151
152 #[cfg(windows)]
153 {
154 if let Ok(metadata) = fs::metadata(prompt_path) {
155 let mut perms = metadata.permissions();
156 perms.set_readonly(true);
157 let _ = fs::set_permissions(prompt_path, perms);
158 }
159 }
160
161 return Ok(false);
162 }
163 }
164
165 anyhow::bail!(
167 "PROMPT.md is missing/empty and no valid backup available (tried .agent/PROMPT.md.backup, .agent/PROMPT.md.backup.1, .agent/PROMPT.md.backup.2)"
168 );
169}
170
171pub(super) fn check_goal_section(content: &str) -> bool {
173 content.contains("## Goal") || content.contains("# Goal")
174}
175
176pub(super) fn check_acceptance_section(content: &str) -> bool {
178 content.contains("## Acceptance")
179 || content.contains("# Acceptance")
180 || content.contains("Acceptance Criteria")
181 || contains_ascii_case_insensitive(content, "acceptance")
182}
183
184pub fn validate_prompt_md(strict: bool, interactive: bool) -> PromptValidationResult {
208 let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
209 let workspace = WorkspaceFs::new(root);
210 validate_prompt_md_with_workspace(&workspace, strict, interactive)
211}
212
213pub fn validate_prompt_md_with_workspace(
228 workspace: &dyn Workspace,
229 strict: bool,
230 interactive: bool,
231) -> PromptValidationResult {
232 let prompt_path = Path::new("PROMPT.md");
233 let file_exists = workspace.exists(prompt_path);
234 let mut result = PromptValidationResult {
235 file_state: if file_exists {
236 FileState::Empty
237 } else {
238 FileState::Missing
239 },
240 has_goal: false,
241 has_acceptance: false,
242 warnings: Vec::new(),
243 errors: Vec::new(),
244 };
245
246 if !result.exists() {
247 if let Some(source) = try_restore_from_backup_with_workspace(workspace, prompt_path) {
249 result.file_state = FileState::Empty;
250 result.warnings.push(format!(
251 "PROMPT.md was missing and was automatically restored from {source}"
252 ));
253 } else {
254 if interactive && std::io::stdout().is_terminal() {
256 result.errors.push(
257 "PROMPT.md not found. Use 'ralph --init <template>' to create one.".to_string(),
258 );
259 } else {
260 result.errors.push(
261 "PROMPT.md not found. Run 'ralph --list-work-guides' to see available Work Guides, \
262 then 'ralph --init <template>' to create one."
263 .to_string(),
264 );
265 }
266 return result;
267 }
268 }
269
270 let content = match workspace.read(prompt_path) {
271 Ok(c) => c,
272 Err(e) => {
273 result.errors.push(format!("Failed to read PROMPT.md: {e}"));
274 return result;
275 }
276 };
277
278 result.file_state = if content.trim().is_empty() {
279 FileState::Empty
280 } else {
281 FileState::Present
282 };
283
284 if !result.has_content() {
285 result.errors.push("PROMPT.md is empty".to_string());
286 return result;
287 }
288
289 result.has_goal = check_goal_section(&content);
291 if !result.has_goal {
292 let msg = "PROMPT.md missing '## Goal' section".to_string();
293 if strict {
294 result.errors.push(msg);
295 } else {
296 result.warnings.push(msg);
297 }
298 }
299
300 result.has_acceptance = check_acceptance_section(&content);
302 if !result.has_acceptance {
303 let msg = "PROMPT.md missing acceptance checks section".to_string();
304 if strict {
305 result.errors.push(msg);
306 } else {
307 result.warnings.push(msg);
308 }
309 }
310
311 result
312}
313
314fn try_restore_from_backup_with_workspace(
316 workspace: &dyn Workspace,
317 prompt_path: &Path,
318) -> Option<String> {
319 let backup_paths = [
320 (
321 Path::new(".agent/PROMPT.md.backup"),
322 ".agent/PROMPT.md.backup",
323 ),
324 (
325 Path::new(".agent/PROMPT.md.backup.1"),
326 ".agent/PROMPT.md.backup.1",
327 ),
328 (
329 Path::new(".agent/PROMPT.md.backup.2"),
330 ".agent/PROMPT.md.backup.2",
331 ),
332 ];
333
334 for (backup_path, name) in backup_paths {
335 if workspace.exists(backup_path) {
336 let Ok(backup_content) = workspace.read(backup_path) else {
337 continue;
338 };
339
340 if backup_content.trim().is_empty() {
341 continue;
342 }
343
344 if workspace.write(prompt_path, &backup_content).is_ok() {
345 return Some(name.to_string());
346 }
347 }
348 }
349
350 None
351}