use crate::workspace::{Workspace, WorkspaceFs};
use std::path::Path;
pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
if needle.len() > haystack.len() {
return false;
}
let needle = needle.as_bytes();
haystack.as_bytes().windows(needle.len()).any(|window| {
window
.iter()
.zip(needle.iter())
.all(|(a, b)| a.eq_ignore_ascii_case(b))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileState {
Missing,
Empty,
Present,
}
#[derive(Debug, Clone)]
pub struct PromptValidationResult {
pub file_state: FileState,
pub has_goal: bool,
pub has_acceptance: bool,
pub warnings: Vec<String>,
pub errors: Vec<String>,
}
impl PromptValidationResult {
#[must_use]
pub const fn exists(&self) -> bool {
matches!(self.file_state, FileState::Present | FileState::Empty)
}
#[must_use]
pub const fn has_content(&self) -> bool {
matches!(self.file_state, FileState::Present)
}
}
impl PromptValidationResult {
#[must_use]
pub const fn is_valid(&self) -> bool {
self.errors.is_empty()
}
#[must_use]
pub const fn is_perfect(&self) -> bool {
self.errors.is_empty() && self.warnings.is_empty()
}
}
pub(super) fn check_goal_section(content: &str) -> bool {
content.contains("## Goal") || content.contains("# Goal")
}
pub(super) fn check_acceptance_section(content: &str) -> bool {
content.contains("## Acceptance")
|| content.contains("# Acceptance")
|| content.contains("Acceptance Criteria")
|| contains_ascii_case_insensitive(content, "acceptance")
}
pub fn validate_prompt_md_with_workspace(
workspace: &dyn Workspace,
strict: bool,
interactive: bool,
) -> PromptValidationResult {
let prompt_path = Path::new("PROMPT.md");
let file_exists = workspace.exists(prompt_path);
let restored_from = (!file_exists)
.then(|| try_restore_from_backup_with_workspace(workspace, prompt_path))
.flatten();
if !file_exists && restored_from.is_none() {
let error = if interactive && std::io::IsTerminal::is_terminal(&std::io::stdout()) {
"PROMPT.md not found. Use 'ralph --init <template>' to create one.".to_string()
} else {
"PROMPT.md not found. Run 'ralph --list-work-guides' to see available Work Guides, \
then 'ralph --init <template>' to create one."
.to_string()
};
return PromptValidationResult {
file_state: FileState::Missing,
has_goal: false,
has_acceptance: false,
warnings: Vec::new(),
errors: vec![error],
};
}
let restoration_warnings: Vec<String> = restored_from
.into_iter()
.map(|source| format!("PROMPT.md was missing and was automatically restored from {source}"))
.collect();
let content = match workspace.read(prompt_path) {
Ok(c) => c,
Err(e) => {
return PromptValidationResult {
file_state: FileState::Empty,
has_goal: false,
has_acceptance: false,
warnings: restoration_warnings,
errors: vec![format!("Failed to read PROMPT.md: {e}")],
};
}
};
let file_state = if content.trim().is_empty() {
FileState::Empty
} else {
FileState::Present
};
if matches!(file_state, FileState::Empty) {
return PromptValidationResult {
file_state,
has_goal: false,
has_acceptance: false,
warnings: restoration_warnings,
errors: vec!["PROMPT.md is empty".to_string()],
};
}
let has_goal = check_goal_section(&content);
let has_acceptance = check_acceptance_section(&content);
let goal_msg = "PROMPT.md missing '## Goal' section".to_string();
let acceptance_msg = "PROMPT.md missing acceptance checks section".to_string();
let warnings = restoration_warnings
.into_iter()
.chain((!strict && !has_goal).then_some(goal_msg.clone()))
.chain((!strict && !has_acceptance).then_some(acceptance_msg.clone()))
.collect();
let errors = [
(strict && !has_goal).then_some(goal_msg),
(strict && !has_acceptance).then_some(acceptance_msg),
]
.into_iter()
.flatten()
.collect();
PromptValidationResult {
file_state,
has_goal,
has_acceptance,
warnings,
errors,
}
}
fn try_restore_from_backup_with_workspace(
workspace: &dyn Workspace,
prompt_path: &Path,
) -> Option<String> {
let backup_paths = [
(
Path::new(".agent/PROMPT.md.backup"),
".agent/PROMPT.md.backup",
),
(
Path::new(".agent/PROMPT.md.backup.1"),
".agent/PROMPT.md.backup.1",
),
(
Path::new(".agent/PROMPT.md.backup.2"),
".agent/PROMPT.md.backup.2",
),
];
backup_paths.into_iter().find_map(|(backup_path, name)| {
workspace
.exists(backup_path)
.then(|| workspace.read(backup_path).ok())
.flatten()
.filter(|backup_content| !backup_content.trim().is_empty())
.filter(|backup_content| workspace.write(prompt_path, backup_content).is_ok())
.map(|_| name.to_string())
})
}