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