Skip to main content

ralph_workflow/files/protection/validation/
helpers.rs

1// Imports and helper functions for PROMPT.md validation.
2
3use 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/// File existence state for PROMPT.md validation.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum FileState {
33    /// File does not exist
34    Missing,
35    /// File exists but is empty
36    Empty,
37    /// File exists with content
38    Present,
39}
40
41/// Result of PROMPT.md validation.
42///
43/// Contains flags indicating what was found and any errors or warnings.
44#[derive(Debug, Clone)]
45// Each boolean represents a distinct aspect of PROMPT.md validation.
46// These are independent flags tracking different validation dimensions, not
47// a state machine, so bools are the appropriate type.
48pub struct PromptValidationResult {
49    /// File existence and content state
50    pub file_state: FileState,
51    /// Whether a Goal section was found
52    pub has_goal: bool,
53    /// Whether an Acceptance section was found
54    pub has_acceptance: bool,
55    /// List of warnings (non-blocking issues)
56    pub warnings: Vec<String>,
57    /// List of errors (blocking issues)
58    pub errors: Vec<String>,
59}
60
61impl PromptValidationResult {
62    /// Returns true if PROMPT.md exists.
63    pub const fn exists(&self) -> bool {
64        matches!(self.file_state, FileState::Present | FileState::Empty)
65    }
66
67    /// Returns true if PROMPT.md has non-empty content.
68    pub const fn has_content(&self) -> bool {
69        matches!(self.file_state, FileState::Present)
70    }
71}
72
73impl PromptValidationResult {
74    /// Returns true if validation passed (no errors).
75    pub const fn is_valid(&self) -> bool {
76        self.errors.is_empty()
77    }
78
79    /// Returns true if validation passed with no warnings.
80    pub const fn is_perfect(&self) -> bool {
81        self.errors.is_empty() && self.warnings.is_empty()
82    }
83}
84
85/// Restore PROMPT.md from backup if missing or empty.
86///
87/// This is a lightweight periodic check called during pipeline execution
88/// to detect and recover from accidental PROMPT.md deletion by agents.
89/// Unlike `validate_prompt_md()`, this function only checks for file
90/// existence and non-empty content - it doesn't validate structure.
91///
92/// # Auto-Restore
93///
94/// If PROMPT.md is missing or empty but a backup exists, the backup is
95/// automatically copied to PROMPT.md. Tries backups in order:
96/// - `.agent/PROMPT.md.backup`
97/// - `.agent/PROMPT.md.backup.1`
98/// - `.agent/PROMPT.md.backup.2`
99///
100/// # Returns
101///
102/// - `Ok(true)` - File exists and has content (no action needed)
103/// - `Ok(false)` - File was restored from backup
104/// - `Err` - File missing/empty and no valid backup available
105#[cfg(test)]
106pub fn restore_prompt_if_needed() -> anyhow::Result<bool> {
107    let prompt_path = Path::new("PROMPT.md");
108
109    // Check if PROMPT.md exists and has content
110    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    // PROMPT.md is missing or empty - try to restore from backup chain
121    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            // Verify backup has content
130            let Ok(backup_content) = fs::read_to_string(backup_path) else {
131                continue;
132            };
133
134            if backup_content.trim().is_empty() {
135                continue; // Try next backup
136            }
137
138            // Restore from backup
139            fs::write(prompt_path, backup_content)?;
140
141            // Set read-only permissions on restored file (best-effort)
142            #[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    // No valid backup available
166    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
171/// Check content for Goal section.
172pub(super) fn check_goal_section(content: &str) -> bool {
173    content.contains("## Goal") || content.contains("# Goal")
174}
175
176/// Check content for Acceptance section.
177pub(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
184/// Validate PROMPT.md structure and content.
185///
186/// Checks for:
187/// - File existence and non-empty content (auto-restores from backup if missing)
188/// - Goal section (## Goal or # Goal)
189/// - Acceptance section (## Acceptance, Acceptance Criteria, or acceptance)
190///
191/// Uses a `WorkspaceFs` rooted at the current directory for all file operations.
192///
193/// # Auto-Restore
194///
195/// If PROMPT.md is missing but `.agent/PROMPT.md.backup` exists, the backup is
196/// automatically copied to PROMPT.md. This prevents accidental deletion by agents.
197///
198/// # Arguments
199///
200/// * `strict` - In strict mode, missing sections are errors; otherwise they're warnings.
201/// * `interactive` - If true and PROMPT.md doesn't exist, prompt to create from template.
202///   Also requires stdout to be a terminal for interactive prompts.
203///
204/// # Returns
205///
206/// A `PromptValidationResult` containing validation findings.
207pub 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
213/// Validate PROMPT.md structure and content using workspace abstraction.
214///
215/// This is the workspace-aware version of [`validate_prompt_md`] for testability.
216/// Uses the provided workspace for all file operations instead of `std::fs`.
217///
218/// # Arguments
219///
220/// * `workspace` - The workspace for file operations
221/// * `strict` - In strict mode, missing sections are errors; otherwise they're warnings.
222/// * `interactive` - If true and PROMPT.md doesn't exist, prompt to create from template.
223///
224/// # Returns
225///
226/// A `PromptValidationResult` containing validation findings.
227pub 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        // Try to restore from backup
248        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            // No backup available
255            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    // Check for Goal section
290    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    // Check for Acceptance section
301    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
314/// Attempt to restore PROMPT.md from backup files using workspace.
315fn 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}