ralph_workflow/files/protection/
validation.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum FileState {
35 Missing,
37 Empty,
39 Present,
41}
42
43#[derive(Debug, Clone)]
47pub struct PromptValidationResult {
51 pub file_state: FileState,
53 pub has_goal: bool,
55 pub has_acceptance: bool,
57 pub warnings: Vec<String>,
59 pub errors: Vec<String>,
61}
62
63impl PromptValidationResult {
64 pub const fn exists(&self) -> bool {
66 matches!(self.file_state, FileState::Present | FileState::Empty)
67 }
68
69 pub const fn has_content(&self) -> bool {
71 matches!(self.file_state, FileState::Present)
72 }
73}
74
75impl PromptValidationResult {
76 pub const fn is_valid(&self) -> bool {
78 self.errors.is_empty()
79 }
80
81 pub const fn is_perfect(&self) -> bool {
83 self.errors.is_empty() && self.warnings.is_empty()
84 }
85}
86
87pub fn restore_prompt_if_needed() -> anyhow::Result<bool> {
108 let prompt_path = Path::new("PROMPT.md");
109
110 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 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 let Ok(backup_content) = fs::read_to_string(backup_path) else {
132 continue;
133 };
134
135 if backup_content.trim().is_empty() {
136 continue; }
138
139 fs::write(prompt_path, backup_content)?;
141
142 #[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 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
172fn 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
217fn check_goal_section(content: &str) -> bool {
219 content.contains("## Goal") || content.contains("# Goal")
220}
221
222fn 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
230pub 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 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 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 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 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
335pub 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 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 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 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 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
437fn 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 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 let was_restored = restore_prompt_if_needed().unwrap();
510 assert!(!was_restored);
511
512 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 let was_restored = restore_prompt_if_needed().unwrap();
527 assert!(!was_restored);
528
529 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 let result = restore_prompt_if_needed();
543 assert!(result.is_err());
544 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 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 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 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 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 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 assert!(result.warnings.iter().any(|w| w.contains("restored from")));
709 assert!(result.has_goal);
710 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()); 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()); assert_eq!(result.errors.len(), 2);
746 }
747}