ralph_workflow/files/protection/validation/
helpers.rs1use crate::workspace::{Workspace, WorkspaceFs};
4use std::path::Path;
5
6pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
7 if needle.is_empty() {
8 return true;
9 }
10 if needle.len() > haystack.len() {
11 return false;
12 }
13
14 let needle = needle.as_bytes();
15 haystack.as_bytes().windows(needle.len()).any(|window| {
16 window
17 .iter()
18 .zip(needle.iter())
19 .all(|(a, b)| a.eq_ignore_ascii_case(b))
20 })
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum FileState {
26 Missing,
28 Empty,
30 Present,
32}
33
34#[derive(Debug, Clone)]
38pub struct PromptValidationResult {
42 pub file_state: FileState,
44 pub has_goal: bool,
46 pub has_acceptance: bool,
48 pub warnings: Vec<String>,
50 pub errors: Vec<String>,
52}
53
54impl PromptValidationResult {
55 #[must_use]
57 pub const fn exists(&self) -> bool {
58 matches!(self.file_state, FileState::Present | FileState::Empty)
59 }
60
61 #[must_use]
63 pub const fn has_content(&self) -> bool {
64 matches!(self.file_state, FileState::Present)
65 }
66}
67
68impl PromptValidationResult {
69 #[must_use]
71 pub const fn is_valid(&self) -> bool {
72 self.errors.is_empty()
73 }
74
75 #[must_use]
77 pub const fn is_perfect(&self) -> bool {
78 self.errors.is_empty() && self.warnings.is_empty()
79 }
80}
81
82pub(super) fn check_goal_section(content: &str) -> bool {
84 content.contains("## Goal") || content.contains("# Goal")
85}
86
87pub(super) fn check_acceptance_section(content: &str) -> bool {
89 content.contains("## Acceptance")
90 || content.contains("# Acceptance")
91 || content.contains("Acceptance Criteria")
92 || contains_ascii_case_insensitive(content, "acceptance")
93}
94
95pub fn validate_prompt_md_with_workspace(
110 workspace: &dyn Workspace,
111 strict: bool,
112 interactive: bool,
113) -> PromptValidationResult {
114 let prompt_path = Path::new("PROMPT.md");
115 let file_exists = workspace.exists(prompt_path);
116 let restored_from = (!file_exists)
117 .then(|| try_restore_from_backup_with_workspace(workspace, prompt_path))
118 .flatten();
119
120 if !file_exists && restored_from.is_none() {
121 let error = if interactive && std::io::IsTerminal::is_terminal(&std::io::stdout()) {
122 "PROMPT.md not found. Use 'ralph --init <template>' to create one.".to_string()
123 } else {
124 "PROMPT.md not found. Run 'ralph --list-work-guides' to see available Work Guides, \
125 then 'ralph --init <template>' to create one."
126 .to_string()
127 };
128
129 return PromptValidationResult {
130 file_state: FileState::Missing,
131 has_goal: false,
132 has_acceptance: false,
133 warnings: Vec::new(),
134 errors: vec![error],
135 };
136 }
137
138 let restoration_warnings: Vec<String> = restored_from
139 .into_iter()
140 .map(|source| format!("PROMPT.md was missing and was automatically restored from {source}"))
141 .collect();
142
143 let content = match workspace.read(prompt_path) {
144 Ok(c) => c,
145 Err(e) => {
146 return PromptValidationResult {
147 file_state: FileState::Empty,
148 has_goal: false,
149 has_acceptance: false,
150 warnings: restoration_warnings,
151 errors: vec![format!("Failed to read PROMPT.md: {e}")],
152 };
153 }
154 };
155
156 let file_state = if content.trim().is_empty() {
157 FileState::Empty
158 } else {
159 FileState::Present
160 };
161
162 if matches!(file_state, FileState::Empty) {
163 return PromptValidationResult {
164 file_state,
165 has_goal: false,
166 has_acceptance: false,
167 warnings: restoration_warnings,
168 errors: vec!["PROMPT.md is empty".to_string()],
169 };
170 }
171
172 let has_goal = check_goal_section(&content);
173 let has_acceptance = check_acceptance_section(&content);
174
175 let goal_msg = "PROMPT.md missing '## Goal' section".to_string();
176 let acceptance_msg = "PROMPT.md missing acceptance checks section".to_string();
177
178 let warnings = restoration_warnings
179 .into_iter()
180 .chain((!strict && !has_goal).then_some(goal_msg.clone()))
181 .chain((!strict && !has_acceptance).then_some(acceptance_msg.clone()))
182 .collect();
183
184 let errors = [
185 (strict && !has_goal).then_some(goal_msg),
186 (strict && !has_acceptance).then_some(acceptance_msg),
187 ]
188 .into_iter()
189 .flatten()
190 .collect();
191
192 PromptValidationResult {
193 file_state,
194 has_goal,
195 has_acceptance,
196 warnings,
197 errors,
198 }
199}
200
201fn try_restore_from_backup_with_workspace(
203 workspace: &dyn Workspace,
204 prompt_path: &Path,
205) -> Option<String> {
206 let backup_paths = [
207 (
208 Path::new(".agent/PROMPT.md.backup"),
209 ".agent/PROMPT.md.backup",
210 ),
211 (
212 Path::new(".agent/PROMPT.md.backup.1"),
213 ".agent/PROMPT.md.backup.1",
214 ),
215 (
216 Path::new(".agent/PROMPT.md.backup.2"),
217 ".agent/PROMPT.md.backup.2",
218 ),
219 ];
220
221 backup_paths.into_iter().find_map(|(backup_path, name)| {
222 workspace
223 .exists(backup_path)
224 .then(|| workspace.read(backup_path).ok())
225 .flatten()
226 .filter(|backup_content| !backup_content.trim().is_empty())
227 .filter(|backup_content| workspace.write(prompt_path, backup_content).is_ok())
228 .map(|_| name.to_string())
229 })
230}