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