Skip to main content

ralph_workflow/files/protection/
validation.rs

1//! PROMPT.md validation utilities.
2//!
3//! Validates the structure and content of PROMPT.md files to ensure
4//! they have the required sections for the pipeline to work effectively.
5
6use crate::workspace::Workspace;
7use std::fs;
8use std::io::IsTerminal;
9use std::path::Path;
10
11fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
12    if needle.is_empty() {
13        return true;
14    }
15    if needle.len() > haystack.len() {
16        return false;
17    }
18
19    let needle = needle.as_bytes();
20    for window in haystack.as_bytes().windows(needle.len()) {
21        if window
22            .iter()
23            .zip(needle.iter())
24            .all(|(a, b)| a.eq_ignore_ascii_case(b))
25        {
26            return true;
27        }
28    }
29    false
30}
31
32/// File existence state for PROMPT.md validation.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum FileState {
35    /// File does not exist
36    Missing,
37    /// File exists but is empty
38    Empty,
39    /// File exists with content
40    Present,
41}
42
43/// Result of PROMPT.md validation.
44///
45/// Contains flags indicating what was found and any errors or warnings.
46#[derive(Debug, Clone)]
47// Each boolean represents a distinct aspect of PROMPT.md validation.
48// These are independent flags tracking different validation dimensions, not
49// a state machine, so bools are the appropriate type.
50pub struct PromptValidationResult {
51    /// File existence and content state
52    pub file_state: FileState,
53    /// Whether a Goal section was found
54    pub has_goal: bool,
55    /// Whether an Acceptance section was found
56    pub has_acceptance: bool,
57    /// List of warnings (non-blocking issues)
58    pub warnings: Vec<String>,
59    /// List of errors (blocking issues)
60    pub errors: Vec<String>,
61}
62
63impl PromptValidationResult {
64    /// Returns true if PROMPT.md exists.
65    pub const fn exists(&self) -> bool {
66        matches!(self.file_state, FileState::Present | FileState::Empty)
67    }
68
69    /// Returns true if PROMPT.md has non-empty content.
70    pub const fn has_content(&self) -> bool {
71        matches!(self.file_state, FileState::Present)
72    }
73}
74
75impl PromptValidationResult {
76    /// Returns true if validation passed (no errors).
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    pub const fn is_perfect(&self) -> bool {
83        self.errors.is_empty() && self.warnings.is_empty()
84    }
85}
86
87/// Restore PROMPT.md from backup if missing or empty.
88///
89/// This is a lightweight periodic check called during pipeline execution
90/// to detect and recover from accidental PROMPT.md deletion by agents.
91/// Unlike `validate_prompt_md()`, this function only checks for file
92/// existence and non-empty content - it doesn't validate structure.
93///
94/// # Auto-Restore
95///
96/// If PROMPT.md is missing or empty but a backup exists, the backup is
97/// automatically copied to PROMPT.md. Tries backups in order:
98/// - `.agent/PROMPT.md.backup`
99/// - `.agent/PROMPT.md.backup.1`
100/// - `.agent/PROMPT.md.backup.2`
101///
102/// # Returns
103///
104/// - `Ok(true)` - File exists and has content (no action needed)
105/// - `Ok(false)` - File was restored from backup
106/// - `Err` - File missing/empty and no valid backup available
107pub fn restore_prompt_if_needed() -> anyhow::Result<bool> {
108    let prompt_path = Path::new("PROMPT.md");
109
110    // Check if PROMPT.md exists and has content
111    let prompt_ok = prompt_path
112        .exists()
113        .then(|| fs::read_to_string(prompt_path).ok())
114        .flatten()
115        .is_some_and(|s| !s.trim().is_empty());
116
117    if prompt_ok {
118        return Ok(true);
119    }
120
121    // PROMPT.md is missing or empty - try to restore from backup chain
122    let backup_paths = [
123        Path::new(".agent/PROMPT.md.backup"),
124        Path::new(".agent/PROMPT.md.backup.1"),
125        Path::new(".agent/PROMPT.md.backup.2"),
126    ];
127
128    for backup_path in &backup_paths {
129        if backup_path.exists() {
130            // Verify backup has content
131            let Ok(backup_content) = fs::read_to_string(backup_path) else {
132                continue;
133            };
134
135            if backup_content.trim().is_empty() {
136                continue; // Try next backup
137            }
138
139            // Restore from backup
140            fs::write(prompt_path, backup_content)?;
141
142            // Set read-only permissions on restored file (best-effort)
143            #[cfg(unix)]
144            {
145                use std::os::unix::fs::PermissionsExt;
146                if let Ok(metadata) = fs::metadata(prompt_path) {
147                    let mut perms = metadata.permissions();
148                    perms.set_mode(0o444);
149                    let _ = fs::set_permissions(prompt_path, perms);
150                }
151            }
152
153            #[cfg(windows)]
154            {
155                if let Ok(metadata) = fs::metadata(prompt_path) {
156                    let mut perms = metadata.permissions();
157                    perms.set_readonly(true);
158                    let _ = fs::set_permissions(prompt_path, perms);
159                }
160            }
161
162            return Ok(false);
163        }
164    }
165
166    // No valid backup available
167    anyhow::bail!(
168        "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)"
169    );
170}
171
172/// Attempt to restore PROMPT.md from backup files.
173///
174/// Tries to restore from backup files in order:
175/// 1. `.agent/PROMPT.md.backup`
176/// 2. `.agent/PROMPT.md.backup.1`
177/// 3. `.agent/PROMPT.md.backup.2`
178///
179/// # Returns
180///
181/// `Some(String)` with the backup source name if restored, `None` otherwise.
182fn try_restore_from_backup(prompt_path: &Path) -> Option<String> {
183    let backup_paths = [
184        (
185            Path::new(".agent/PROMPT.md.backup"),
186            ".agent/PROMPT.md.backup",
187        ),
188        (
189            Path::new(".agent/PROMPT.md.backup.1"),
190            ".agent/PROMPT.md.backup.1",
191        ),
192        (
193            Path::new(".agent/PROMPT.md.backup.2"),
194            ".agent/PROMPT.md.backup.2",
195        ),
196    ];
197
198    for (backup_path, name) in backup_paths {
199        if backup_path.exists() {
200            let Ok(backup_content) = fs::read_to_string(backup_path) else {
201                continue;
202            };
203
204            if backup_content.trim().is_empty() {
205                continue;
206            }
207
208            if fs::copy(backup_path, prompt_path).is_ok() {
209                return Some(name.to_string());
210            }
211        }
212    }
213
214    None
215}
216
217/// Check content for Goal section.
218fn check_goal_section(content: &str) -> bool {
219    content.contains("## Goal") || content.contains("# Goal")
220}
221
222/// Check content for Acceptance section.
223fn check_acceptance_section(content: &str) -> bool {
224    content.contains("## Acceptance")
225        || content.contains("# Acceptance")
226        || content.contains("Acceptance Criteria")
227        || contains_ascii_case_insensitive(content, "acceptance")
228}
229
230/// Validate PROMPT.md structure and content.
231///
232/// Checks for:
233/// - File existence and non-empty content (auto-restores from backup if missing)
234/// - Goal section (## Goal or # Goal)
235/// - Acceptance section (## Acceptance, Acceptance Criteria, or acceptance)
236///
237/// # Auto-Restore
238///
239/// If PROMPT.md is missing but `.agent/PROMPT.md.backup` exists, the backup is
240/// automatically copied to PROMPT.md. This prevents accidental deletion by agents.
241///
242/// # Arguments
243///
244/// * `strict` - In strict mode, missing sections are errors; otherwise they're warnings.
245/// * `interactive` - If true and PROMPT.md doesn't exist, prompt to create from template.
246///   Also requires stdout to be a terminal for interactive prompts.
247///
248/// # Returns
249///
250/// A `PromptValidationResult` containing validation findings.
251pub fn validate_prompt_md(strict: bool, interactive: bool) -> PromptValidationResult {
252    let prompt_path = Path::new("PROMPT.md");
253    let file_exists = prompt_path.exists();
254    let mut result = PromptValidationResult {
255        file_state: if file_exists {
256            FileState::Empty
257        } else {
258            FileState::Missing
259        },
260        has_goal: false,
261        has_acceptance: false,
262        warnings: Vec::new(),
263        errors: Vec::new(),
264    };
265
266    if !result.exists() {
267        // Try to restore from backup
268        if let Some(source) = try_restore_from_backup(prompt_path) {
269            result.file_state = FileState::Empty;
270            result.warnings.push(format!(
271                "PROMPT.md was missing and was automatically restored from {source}"
272            ));
273        } else {
274            // No backup available
275            if interactive && std::io::stdout().is_terminal() {
276                result.errors.push(
277                    "PROMPT.md not found. Use 'ralph --init-prompt <template>' to create one."
278                        .to_string(),
279                );
280            } else {
281                result.errors.push(
282                    "PROMPT.md not found. Run 'ralph --list-work-guides' to see available Work Guides, \
283                     then 'ralph --init <template>' to create one."
284                        .to_string(),
285                );
286            }
287            return result;
288        }
289    }
290
291    let content = match fs::read_to_string(prompt_path) {
292        Ok(c) => c,
293        Err(e) => {
294            result.errors.push(format!("Failed to read PROMPT.md: {e}"));
295            return result;
296        }
297    };
298
299    result.file_state = if content.trim().is_empty() {
300        FileState::Empty
301    } else {
302        FileState::Present
303    };
304
305    if !result.has_content() {
306        result.errors.push("PROMPT.md is empty".to_string());
307        return result;
308    }
309
310    // Check for Goal section
311    result.has_goal = check_goal_section(&content);
312    if !result.has_goal {
313        let msg = "PROMPT.md missing '## Goal' section".to_string();
314        if strict {
315            result.errors.push(msg);
316        } else {
317            result.warnings.push(msg);
318        }
319    }
320
321    // Check for Acceptance section
322    result.has_acceptance = check_acceptance_section(&content);
323    if !result.has_acceptance {
324        let msg = "PROMPT.md missing acceptance checks section".to_string();
325        if strict {
326            result.errors.push(msg);
327        } else {
328            result.warnings.push(msg);
329        }
330    }
331
332    result
333}
334
335/// Validate PROMPT.md structure and content using workspace abstraction.
336///
337/// This is the workspace-aware version of [`validate_prompt_md`] for testability.
338/// Uses the provided workspace for all file operations instead of `std::fs`.
339///
340/// # Arguments
341///
342/// * `workspace` - The workspace for file operations
343/// * `strict` - In strict mode, missing sections are errors; otherwise they're warnings.
344/// * `interactive` - If true and PROMPT.md doesn't exist, prompt to create from template.
345///
346/// # Returns
347///
348/// A `PromptValidationResult` containing validation findings.
349pub fn validate_prompt_md_with_workspace(
350    workspace: &dyn Workspace,
351    strict: bool,
352    interactive: bool,
353) -> PromptValidationResult {
354    let prompt_path = Path::new("PROMPT.md");
355    let file_exists = workspace.exists(prompt_path);
356    let mut result = PromptValidationResult {
357        file_state: if file_exists {
358            FileState::Empty
359        } else {
360            FileState::Missing
361        },
362        has_goal: false,
363        has_acceptance: false,
364        warnings: Vec::new(),
365        errors: Vec::new(),
366    };
367
368    if !result.exists() {
369        // Try to restore from backup
370        if let Some(source) = try_restore_from_backup_with_workspace(workspace, prompt_path) {
371            result.file_state = FileState::Empty;
372            result.warnings.push(format!(
373                "PROMPT.md was missing and was automatically restored from {source}"
374            ));
375        } else {
376            // No backup available
377            if interactive && std::io::stdout().is_terminal() {
378                result.errors.push(
379                    "PROMPT.md not found. Use 'ralph --init-prompt <template>' to create one."
380                        .to_string(),
381                );
382            } else {
383                result.errors.push(
384                    "PROMPT.md not found. Run 'ralph --list-work-guides' to see available Work Guides, \
385                     then 'ralph --init <template>' to create one."
386                        .to_string(),
387                );
388            }
389            return result;
390        }
391    }
392
393    let content = match workspace.read(prompt_path) {
394        Ok(c) => c,
395        Err(e) => {
396            result.errors.push(format!("Failed to read PROMPT.md: {e}"));
397            return result;
398        }
399    };
400
401    result.file_state = if content.trim().is_empty() {
402        FileState::Empty
403    } else {
404        FileState::Present
405    };
406
407    if !result.has_content() {
408        result.errors.push("PROMPT.md is empty".to_string());
409        return result;
410    }
411
412    // Check for Goal section
413    result.has_goal = check_goal_section(&content);
414    if !result.has_goal {
415        let msg = "PROMPT.md missing '## Goal' section".to_string();
416        if strict {
417            result.errors.push(msg);
418        } else {
419            result.warnings.push(msg);
420        }
421    }
422
423    // Check for Acceptance section
424    result.has_acceptance = check_acceptance_section(&content);
425    if !result.has_acceptance {
426        let msg = "PROMPT.md missing acceptance checks section".to_string();
427        if strict {
428            result.errors.push(msg);
429        } else {
430            result.warnings.push(msg);
431        }
432    }
433
434    result
435}
436
437/// Attempt to restore PROMPT.md from backup files using workspace.
438fn try_restore_from_backup_with_workspace(
439    workspace: &dyn Workspace,
440    prompt_path: &Path,
441) -> Option<String> {
442    let backup_paths = [
443        (
444            Path::new(".agent/PROMPT.md.backup"),
445            ".agent/PROMPT.md.backup",
446        ),
447        (
448            Path::new(".agent/PROMPT.md.backup.1"),
449            ".agent/PROMPT.md.backup.1",
450        ),
451        (
452            Path::new(".agent/PROMPT.md.backup.2"),
453            ".agent/PROMPT.md.backup.2",
454        ),
455    ];
456
457    for (backup_path, name) in backup_paths {
458        if workspace.exists(backup_path) {
459            let Ok(backup_content) = workspace.read(backup_path) else {
460                continue;
461            };
462
463            if backup_content.trim().is_empty() {
464                continue;
465            }
466
467            if workspace.write(prompt_path, &backup_content).is_ok() {
468                return Some(name.to_string());
469            }
470        }
471    }
472
473    None
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use test_helpers::with_temp_cwd;
480
481    #[test]
482    fn test_restore_prompt_if_needed_ok() {
483        with_temp_cwd(|_dir| {
484            fs::write("PROMPT.md", "# Test\n\nContent").unwrap();
485            assert!(restore_prompt_if_needed().unwrap());
486        });
487    }
488
489    #[test]
490    fn test_restore_prompt_if_needed_missing() {
491        with_temp_cwd(|_dir| {
492            // No PROMPT.md, no backup
493            let result = restore_prompt_if_needed();
494            assert!(result.is_err());
495            assert!(result
496                .unwrap_err()
497                .to_string()
498                .contains("no valid backup available"));
499        });
500    }
501
502    #[test]
503    fn test_restore_prompt_if_needed_restores_from_backup() {
504        with_temp_cwd(|_dir| {
505            fs::create_dir_all(".agent").unwrap();
506            fs::write(".agent/PROMPT.md.backup", "# Restored\n\nContent").unwrap();
507
508            // File is missing, should restore from backup
509            let was_restored = restore_prompt_if_needed().unwrap();
510            assert!(!was_restored);
511
512            // Verify PROMPT.md exists with backup content
513            let content = fs::read_to_string("PROMPT.md").unwrap();
514            assert_eq!(content, "# Restored\n\nContent");
515        });
516    }
517
518    #[test]
519    fn test_restore_prompt_if_needed_empty_file() {
520        with_temp_cwd(|_dir| {
521            fs::create_dir_all(".agent").unwrap();
522            fs::write("PROMPT.md", "").unwrap();
523            fs::write(".agent/PROMPT.md.backup", "# Restored\n\nContent").unwrap();
524
525            // File is empty, should restore from backup
526            let was_restored = restore_prompt_if_needed().unwrap();
527            assert!(!was_restored);
528
529            // Verify PROMPT.md has backup content
530            let content = fs::read_to_string("PROMPT.md").unwrap();
531            assert_eq!(content, "# Restored\n\nContent");
532        });
533    }
534
535    #[test]
536    fn test_restore_prompt_if_needed_empty_backup() {
537        with_temp_cwd(|_dir| {
538            fs::create_dir_all(".agent").unwrap();
539            fs::write(".agent/PROMPT.md.backup", "").unwrap();
540
541            // Backup is empty, should fail
542            let result = restore_prompt_if_needed();
543            assert!(result.is_err());
544            // Error should mention no valid backup (since empty backup is skipped)
545            assert!(result
546                .unwrap_err()
547                .to_string()
548                .contains("no valid backup available"));
549        });
550    }
551
552    #[test]
553    fn test_validate_prompt_md_not_exists() {
554        with_temp_cwd(|_dir| {
555            let result = validate_prompt_md(false, false);
556            assert!(!result.exists());
557            assert!(!result.is_valid());
558            assert!(result.errors.iter().any(|e| e.contains("not found")));
559            // Verify Work Guide suggestion is included
560            assert!(result
561                .errors
562                .iter()
563                .any(|e| e.contains("--list-work-guides") || e.contains("--init")));
564        });
565    }
566
567    #[test]
568    fn test_validate_prompt_md_empty() {
569        with_temp_cwd(|_dir| {
570            fs::write("PROMPT.md", "   \n\n  ").unwrap();
571            let result = validate_prompt_md(false, false);
572            assert!(result.exists());
573            assert!(!result.has_content());
574            assert!(!result.is_valid());
575            assert!(result.errors.iter().any(|e| e.contains("empty")));
576        });
577    }
578
579    #[test]
580    fn test_validate_prompt_md_complete() {
581        with_temp_cwd(|_dir| {
582            fs::write(
583                "PROMPT.md",
584                "# PROMPT
585
586## Goal
587Build a feature
588
589## Acceptance
590- Tests pass
591",
592            )
593            .unwrap();
594            let result = validate_prompt_md(false, false);
595            assert!(result.exists());
596            assert!(result.has_content());
597            assert!(result.has_goal);
598            assert!(result.has_acceptance);
599            assert!(result.is_valid());
600            assert!(result.is_perfect());
601        });
602    }
603
604    #[test]
605    fn test_validate_prompt_md_missing_sections_lenient() {
606        with_temp_cwd(|_dir| {
607            fs::write("PROMPT.md", "Just some random content").unwrap();
608            let result = validate_prompt_md(false, false);
609            assert!(result.exists());
610            assert!(result.has_content());
611            assert!(!result.has_goal);
612            assert!(!result.has_acceptance);
613            // In lenient mode, missing sections are warnings, not errors
614            assert!(result.is_valid());
615            assert!(!result.is_perfect());
616            assert_eq!(result.warnings.len(), 2);
617        });
618    }
619
620    #[test]
621    fn test_validate_prompt_md_missing_sections_strict() {
622        with_temp_cwd(|_dir| {
623            fs::write("PROMPT.md", "Just some random content").unwrap();
624            let result = validate_prompt_md(true, false);
625            assert!(result.exists());
626            assert!(result.has_content());
627            assert!(!result.has_goal);
628            assert!(!result.has_acceptance);
629            // In strict mode, missing sections are errors
630            assert!(!result.is_valid());
631            assert_eq!(result.errors.len(), 2);
632        });
633    }
634
635    #[test]
636    fn test_validate_prompt_md_acceptance_variations() {
637        with_temp_cwd(|_dir| {
638            // Test "Acceptance Criteria" variant
639            fs::write(
640                "PROMPT.md",
641                "## Goal
642Test
643
644## Acceptance Criteria
645- Pass
646",
647            )
648            .unwrap();
649            let result = validate_prompt_md(false, false);
650            assert!(result.has_acceptance);
651
652            // Test lowercase "acceptance" variant
653            fs::write(
654                "PROMPT.md",
655                "## Goal
656Test
657
658The acceptance tests should pass.
659",
660            )
661            .unwrap();
662            let result = validate_prompt_md(false, false);
663            assert!(result.has_acceptance);
664        });
665    }
666}
667
668#[cfg(all(test, feature = "test-utils"))]
669mod workspace_tests {
670    use super::*;
671    use crate::workspace::{MemoryWorkspace, Workspace};
672
673    #[test]
674    fn test_validate_prompt_md_with_workspace_not_exists() {
675        let workspace = MemoryWorkspace::new_test();
676
677        let result = validate_prompt_md_with_workspace(&workspace, false, false);
678
679        assert!(!result.exists());
680        assert!(!result.is_valid());
681        assert!(result.errors.iter().any(|e| e.contains("not found")));
682    }
683
684    #[test]
685    fn test_validate_prompt_md_with_workspace_valid() {
686        let workspace = MemoryWorkspace::new_test().with_file(
687            "PROMPT.md",
688            "# Test\n\n## Goal\nDo something\n\n## Acceptance\n- Pass",
689        );
690
691        let result = validate_prompt_md_with_workspace(&workspace, false, false);
692
693        assert!(result.exists());
694        assert!(result.has_content());
695        assert!(result.has_goal);
696        assert!(result.has_acceptance);
697        assert!(result.is_valid());
698    }
699
700    #[test]
701    fn test_validate_prompt_md_with_workspace_restores_from_backup() {
702        let workspace =
703            MemoryWorkspace::new_test().with_file(".agent/PROMPT.md.backup", "## Goal\nRestored");
704
705        let result = validate_prompt_md_with_workspace(&workspace, false, false);
706
707        // Should have restored from backup
708        assert!(result.warnings.iter().any(|w| w.contains("restored from")));
709        assert!(result.has_goal);
710        // PROMPT.md should now exist in workspace
711        assert!(workspace.exists(Path::new("PROMPT.md")));
712    }
713
714    #[test]
715    fn test_validate_prompt_md_with_workspace_empty() {
716        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "   \n\n  ");
717
718        let result = validate_prompt_md_with_workspace(&workspace, false, false);
719
720        assert!(result.exists());
721        assert!(!result.has_content());
722        assert!(!result.is_valid());
723        assert!(result.errors.iter().any(|e| e.contains("empty")));
724    }
725
726    #[test]
727    fn test_validate_prompt_md_with_workspace_missing_sections_lenient() {
728        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "Just some content");
729
730        let result = validate_prompt_md_with_workspace(&workspace, false, false);
731
732        assert!(result.is_valid()); // Lenient mode: warnings, not errors
733        assert!(!result.has_goal);
734        assert!(!result.has_acceptance);
735        assert_eq!(result.warnings.len(), 2);
736    }
737
738    #[test]
739    fn test_validate_prompt_md_with_workspace_missing_sections_strict() {
740        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "Just some content");
741
742        let result = validate_prompt_md_with_workspace(&workspace, true, false);
743
744        assert!(!result.is_valid()); // Strict mode: errors
745        assert_eq!(result.errors.len(), 2);
746    }
747}