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