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
87#[cfg(test)]
108pub fn restore_prompt_if_needed() -> anyhow::Result<bool> {
109 let prompt_path = Path::new("PROMPT.md");
110
111 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 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 let Ok(backup_content) = fs::read_to_string(backup_path) else {
133 continue;
134 };
135
136 if backup_content.trim().is_empty() {
137 continue; }
139
140 fs::write(prompt_path, backup_content)?;
142
143 #[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 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
173fn 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
218fn check_goal_section(content: &str) -> bool {
220 content.contains("## Goal") || content.contains("# Goal")
221}
222
223fn 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
231pub 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 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 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 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 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
336pub 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 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 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 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 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
438fn 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 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 let was_restored = restore_prompt_if_needed().unwrap();
511 assert!(!was_restored);
512
513 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 let was_restored = restore_prompt_if_needed().unwrap();
528 assert!(!was_restored);
529
530 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 let result = restore_prompt_if_needed();
544 assert!(result.is_err());
545 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 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 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 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 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 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 assert!(result.warnings.iter().any(|w| w.contains("restored from")));
710 assert!(result.has_goal);
711 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()); 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()); assert_eq!(result.errors.len(), 2);
747 }
748}