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::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/// File existence state for PROMPT.md validation.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum FileState {
26    /// File does not exist
27    Missing,
28    /// File exists but is empty
29    Empty,
30    /// File exists with content
31    Present,
32}
33
34/// Result of PROMPT.md validation.
35///
36/// Contains flags indicating what was found and any errors or warnings.
37#[derive(Debug, Clone)]
38// Each boolean represents a distinct aspect of PROMPT.md validation.
39// These are independent flags tracking different validation dimensions, not
40// a state machine, so bools are the appropriate type.
41pub struct PromptValidationResult {
42    /// File existence and content state
43    pub file_state: FileState,
44    /// Whether a Goal section was found
45    pub has_goal: bool,
46    /// Whether an Acceptance section was found
47    pub has_acceptance: bool,
48    /// List of warnings (non-blocking issues)
49    pub warnings: Vec<String>,
50    /// List of errors (blocking issues)
51    pub errors: Vec<String>,
52}
53
54impl PromptValidationResult {
55    /// Returns true if PROMPT.md exists.
56    #[must_use]
57    pub const fn exists(&self) -> bool {
58        matches!(self.file_state, FileState::Present | FileState::Empty)
59    }
60
61    /// Returns true if PROMPT.md has non-empty content.
62    #[must_use]
63    pub const fn has_content(&self) -> bool {
64        matches!(self.file_state, FileState::Present)
65    }
66}
67
68impl PromptValidationResult {
69    /// Returns true if validation passed (no errors).
70    #[must_use]
71    pub const fn is_valid(&self) -> bool {
72        self.errors.is_empty()
73    }
74
75    /// Returns true if validation passed with no warnings.
76    #[must_use]
77    pub const fn is_perfect(&self) -> bool {
78        self.errors.is_empty() && self.warnings.is_empty()
79    }
80}
81
82/// Check content for Goal section.
83pub(super) fn check_goal_section(content: &str) -> bool {
84    content.contains("## Goal") || content.contains("# Goal")
85}
86
87/// Check content for Acceptance section.
88pub(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
95/// Validate PROMPT.md structure and content using workspace abstraction.
96///
97/// This is the workspace-aware version of [`validate_prompt_md`] for testability.
98/// Uses the provided workspace for all file operations instead of `std::fs`.
99///
100/// # Arguments
101///
102/// * `workspace` - The workspace for file operations
103/// * `strict` - In strict mode, missing sections are errors; otherwise they're warnings.
104/// * `interactive` - If true and PROMPT.md doesn't exist, prompt to create from template.
105///
106/// # Returns
107///
108/// A `PromptValidationResult` containing validation findings.
109pub 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
201/// Attempt to restore PROMPT.md from backup files using workspace.
202fn 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}