1use crate::checkpoint::execution_history::FileSnapshot;
7use crate::executor::{ProcessExecutor, RealProcessExecutor};
8use crate::workspace::Workspace;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18pub struct FileSystemState {
19 pub files: HashMap<String, FileSnapshot>,
21 pub git_head_oid: Option<String>,
23 pub git_branch: Option<String>,
25 pub git_status: Option<String>,
27 pub git_modified_files: Option<Vec<String>>,
29}
30
31impl FileSystemState {
32 pub fn new() -> Self {
34 Self::default()
35 }
36
37 #[deprecated(
51 since = "0.5.0",
52 note = "Uses CWD-relative paths. Use capture_with_workspace instead."
53 )]
54 pub fn capture_with_optional_executor(executor: Option<&dyn ProcessExecutor>) -> Self {
55 match executor {
57 Some(exec) => Self::capture_current_with_executor_impl(exec),
58 None => {
59 let real_executor = RealProcessExecutor::new();
62 Self::capture_current_with_executor_impl(&real_executor)
63 }
64 }
65 }
66
67 pub(crate) fn capture_with_optional_executor_impl(
73 executor: Option<&dyn ProcessExecutor>,
74 ) -> Self {
75 match executor {
76 Some(exec) => Self::capture_current_with_executor_impl(exec),
77 None => {
78 let real_executor = RealProcessExecutor::new();
79 Self::capture_current_with_executor_impl(&real_executor)
80 }
81 }
82 }
83
84 fn capture_current_with_executor_impl(executor: &dyn ProcessExecutor) -> Self {
90 let mut state = Self::new();
91
92 state.capture_file_impl("PROMPT.md");
94
95 if Path::new(".agent/PLAN.md").exists() {
97 state.capture_file_impl(".agent/PLAN.md");
98 }
99
100 if Path::new(".agent/ISSUES.md").exists() {
102 state.capture_file_impl(".agent/ISSUES.md");
103 }
104
105 if Path::new(".agent/config.toml").exists() {
107 state.capture_file_impl(".agent/config.toml");
108 }
109
110 if Path::new(".agent/start_commit").exists() {
112 state.capture_file_impl(".agent/start_commit");
113 }
114
115 if Path::new(".agent/NOTES.md").exists() {
117 state.capture_file_impl(".agent/NOTES.md");
118 }
119
120 if Path::new(".agent/status").exists() {
122 state.capture_file_impl(".agent/status");
123 }
124
125 state.capture_git_state(executor);
127
128 state
129 }
130
131 pub fn capture_with_workspace(
142 workspace: &dyn Workspace,
143 executor: &dyn ProcessExecutor,
144 ) -> Self {
145 let mut state = Self::new();
146
147 state.capture_file_with_workspace(workspace, "PROMPT.md");
149
150 if workspace.exists(Path::new(".agent/PLAN.md")) {
152 state.capture_file_with_workspace(workspace, ".agent/PLAN.md");
153 }
154
155 if workspace.exists(Path::new(".agent/ISSUES.md")) {
157 state.capture_file_with_workspace(workspace, ".agent/ISSUES.md");
158 }
159
160 if workspace.exists(Path::new(".agent/config.toml")) {
162 state.capture_file_with_workspace(workspace, ".agent/config.toml");
163 }
164
165 if workspace.exists(Path::new(".agent/start_commit")) {
167 state.capture_file_with_workspace(workspace, ".agent/start_commit");
168 }
169
170 if workspace.exists(Path::new(".agent/NOTES.md")) {
172 state.capture_file_with_workspace(workspace, ".agent/NOTES.md");
173 }
174
175 if workspace.exists(Path::new(".agent/status")) {
177 state.capture_file_with_workspace(workspace, ".agent/status");
178 }
179
180 state.capture_git_state(executor);
182
183 state
184 }
185
186 #[deprecated(
201 since = "0.5.0",
202 note = "Uses CWD-relative paths. Use capture_with_workspace instead."
203 )]
204 pub fn capture_current_with_executor(executor: &dyn ProcessExecutor) -> Self {
205 Self::capture_current_with_executor_impl(executor)
206 }
207
208 pub fn capture_file_with_workspace(&mut self, workspace: &dyn Workspace, path: &str) {
210 let path_ref = Path::new(path);
211 let snapshot = if workspace.exists(path_ref) {
212 if let Ok(content) = workspace.read_bytes(path_ref) {
213 let checksum = crate::checkpoint::state::calculate_checksum_from_bytes(&content);
214 let size = content.len() as u64;
215 FileSnapshot::new(path, checksum, size, true)
216 } else {
217 FileSnapshot::not_found(path)
218 }
219 } else {
220 FileSnapshot::not_found(path)
221 };
222
223 self.files.insert(path.to_string(), snapshot);
224 }
225
226 fn capture_file_impl(&mut self, path: &str) {
230 let path_obj = Path::new(path);
231 let snapshot = if path_obj.exists() {
232 if let Some(checksum) = crate::checkpoint::state::calculate_file_checksum(path_obj) {
233 let metadata = std::fs::metadata(path_obj);
234 let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
235 FileSnapshot::new(path, checksum, size, true)
236 } else {
237 FileSnapshot::not_found(path)
238 }
239 } else {
240 FileSnapshot::not_found(path)
241 };
242
243 self.files.insert(path.to_string(), snapshot);
244 }
245
246 #[deprecated(
252 since = "0.5.0",
253 note = "Uses CWD-relative paths. Use capture_file_with_workspace instead."
254 )]
255 pub fn capture_file(&mut self, path: &str) {
256 self.capture_file_impl(path);
257 }
258
259 fn capture_git_state(&mut self, executor: &dyn ProcessExecutor) {
261 if let Ok(output) = executor.execute("git", &["rev-parse", "HEAD"], &[], None) {
263 if output.status.success() {
264 let oid = output.stdout.trim().to_string();
265 self.git_head_oid = Some(oid);
266 }
267 }
268
269 if let Ok(output) =
271 executor.execute("git", &["rev-parse", "--abbrev-ref", "HEAD"], &[], None)
272 {
273 if output.status.success() {
274 let branch = output.stdout.trim().to_string();
275 if !branch.is_empty() && branch != "HEAD" {
276 self.git_branch = Some(branch);
277 }
278 }
279 }
280
281 if let Ok(output) = executor.execute("git", &["status", "--porcelain"], &[], None) {
283 if output.status.success() {
284 let status = output.stdout.trim().to_string();
285 if !status.is_empty() {
286 self.git_status = Some(status);
287 }
288 }
289 }
290
291 if let Ok(output) = executor.execute("git", &["diff", "--name-only"], &[], None) {
293 if output.status.success() {
294 let diff_output = &output.stdout;
295 let modified_files: Vec<String> = diff_output
296 .lines()
297 .map(|line| line.trim().to_string())
298 .filter(|line| !line.is_empty())
299 .collect();
300 if !modified_files.is_empty() {
301 self.git_modified_files = Some(modified_files);
302 }
303 }
304 }
305 }
306
307 #[deprecated(
315 since = "0.5.0",
316 note = "Uses CWD-relative paths. Use validate_with_workspace instead."
317 )]
318 pub fn validate(&self) -> Vec<ValidationError> {
319 self.validate_with_executor_impl(None)
320 }
321
322 pub fn validate_with_workspace(
326 &self,
327 workspace: &dyn Workspace,
328 executor: Option<&dyn ProcessExecutor>,
329 ) -> Vec<ValidationError> {
330 let mut errors = Vec::new();
331
332 for (path, snapshot) in &self.files {
334 if let Err(e) = self.validate_file_with_workspace(workspace, path, snapshot) {
335 errors.push(e);
336 }
337 }
338
339 if let Some(exec) = executor {
341 if let Err(e) = self.validate_git_state_with_executor(exec) {
342 errors.push(e);
343 }
344 }
345
346 errors
347 }
348
349 pub(crate) fn validate_with_executor_impl(
359 &self,
360 executor: Option<&dyn ProcessExecutor>,
361 ) -> Vec<ValidationError> {
362 let mut errors = Vec::new();
363
364 for (path, snapshot) in &self.files {
366 if let Err(e) = self.validate_file_impl(path, snapshot) {
367 errors.push(e);
368 }
369 }
370
371 if let Some(exec) = executor {
373 if let Err(e) = self.validate_git_state_with_executor(exec) {
374 errors.push(e);
375 }
376 }
377
378 errors
379 }
380
381 #[deprecated(
382 since = "0.5.0",
383 note = "Uses CWD-relative paths. Use validate_with_workspace instead."
384 )]
385 pub fn validate_with_executor(
386 &self,
387 executor: Option<&dyn ProcessExecutor>,
388 ) -> Vec<ValidationError> {
389 self.validate_with_executor_impl(executor)
390 }
391
392 fn validate_file_with_workspace(
394 &self,
395 workspace: &dyn Workspace,
396 path: &str,
397 snapshot: &FileSnapshot,
398 ) -> Result<(), ValidationError> {
399 let path_ref = Path::new(path);
400
401 if snapshot.exists && !workspace.exists(path_ref) {
403 return Err(ValidationError::FileMissing {
404 path: path.to_string(),
405 });
406 }
407
408 if !snapshot.exists && workspace.exists(path_ref) {
409 return Err(ValidationError::FileUnexpectedlyExists {
410 path: path.to_string(),
411 });
412 }
413
414 if snapshot.exists && !snapshot.verify_with_workspace(workspace) {
416 return Err(ValidationError::FileContentChanged {
417 path: path.to_string(),
418 });
419 }
420
421 Ok(())
422 }
423
424 fn validate_file_impl(
426 &self,
427 path: &str,
428 snapshot: &FileSnapshot,
429 ) -> Result<(), ValidationError> {
430 let path_obj = Path::new(path);
431
432 if snapshot.exists && !path_obj.exists() {
434 return Err(ValidationError::FileMissing {
435 path: path.to_string(),
436 });
437 }
438
439 if !snapshot.exists && path_obj.exists() {
440 return Err(ValidationError::FileUnexpectedlyExists {
441 path: path.to_string(),
442 });
443 }
444
445 if snapshot.exists {
448 let content = std::fs::read(path_obj);
450 let matches = match content {
451 Ok(bytes) => {
452 if bytes.len() as u64 != snapshot.size {
453 false
454 } else {
455 let checksum =
456 crate::checkpoint::state::calculate_checksum_from_bytes(&bytes);
457 checksum == snapshot.checksum
458 }
459 }
460 Err(_) => false,
461 };
462 if !matches {
463 return Err(ValidationError::FileContentChanged {
464 path: path.to_string(),
465 });
466 }
467 }
468
469 Ok(())
470 }
471
472 fn validate_git_state_with_executor(
474 &self,
475 executor: &dyn ProcessExecutor,
476 ) -> Result<(), ValidationError> {
477 if let Some(expected_oid) = &self.git_head_oid {
479 if let Ok(output) = executor.execute("git", &["rev-parse", "HEAD"], &[], None) {
480 if output.status.success() {
481 let current_oid = output.stdout.trim().to_string();
482 if current_oid != *expected_oid {
483 return Err(ValidationError::GitHeadChanged {
484 expected: expected_oid.clone(),
485 actual: current_oid,
486 });
487 }
488 }
489 }
490 }
491
492 Ok(())
493 }
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498pub enum ValidationError {
499 FileMissing { path: String },
501
502 FileUnexpectedlyExists { path: String },
504
505 FileContentChanged { path: String },
507
508 GitHeadChanged { expected: String, actual: String },
510
511 GitWorkingTreeChanged { changes: String },
513
514 GitStateInvalid { reason: String },
516}
517
518impl std::fmt::Display for ValidationError {
519 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520 match self {
521 Self::FileMissing { path } => {
522 write!(f, "File missing: {}", path)
523 }
524 Self::FileUnexpectedlyExists { path } => {
525 write!(f, "File unexpectedly exists: {}", path)
526 }
527 Self::FileContentChanged { path } => {
528 write!(f, "File content changed: {}", path)
529 }
530 Self::GitHeadChanged { expected, actual } => {
531 write!(f, "Git HEAD changed: expected {}, got {}", expected, actual)
532 }
533 Self::GitWorkingTreeChanged { changes } => {
534 write!(f, "Git working tree changed: {}", changes)
535 }
536 Self::GitStateInvalid { reason } => {
537 write!(f, "Git state invalid: {}", reason)
538 }
539 }
540 }
541}
542
543impl std::error::Error for ValidationError {}
544
545impl ValidationError {
547 pub fn recovery_commands(&self) -> (String, Vec<String>) {
553 match self {
554 Self::FileMissing { path } => {
555 let problem = format!(
556 "The file '{}' is missing but was present when the checkpoint was created.",
557 path
558 );
559 let commands = if path.contains("PROMPT.md") {
560 vec![
561 format!("# Check if file exists elsewhere"),
562 format!("find . -name 'PROMPT.md' -type f 2>/dev/null"),
563 format!(""),
564 format!("# Or recreate from requirements"),
565 format!("# Restore from backup or recreate PROMPT.md"),
566 format!(""),
567 format!("# If unrecoverable, delete checkpoint to start fresh"),
568 format!("rm .agent/checkpoint.json"),
569 ]
570 } else if path.contains(".agent/") {
571 vec![
572 format!("# Agent files should be restored from checkpoint if available"),
573 format!(""),
574 format!("# Or delete checkpoint to start fresh"),
575 format!("rm .agent/checkpoint.json"),
576 ]
577 } else {
578 vec![
579 format!("# Restore from backup or recreate"),
580 format!("git checkout HEAD -- {}", path),
581 format!(""),
582 format!("# Or if unrecoverable, delete checkpoint"),
583 format!("rm .agent/checkpoint.json"),
584 ]
585 };
586 (problem, commands)
587 }
588 Self::FileUnexpectedlyExists { path } => {
589 let problem = format!("The file '{}' should not exist but was found.", path);
590 let commands = vec![
591 format!("# Review the file to see if it should be kept"),
592 format!("cat {}", path),
593 format!(""),
594 format!("# If it should be removed:"),
595 format!("rm {}", path),
596 format!(""),
597 format!("# Or if it should be kept, delete the checkpoint to start fresh"),
598 format!("rm .agent/checkpoint.json"),
599 ];
600 (problem, commands)
601 }
602 Self::FileContentChanged { path } => {
603 let problem = format!(
604 "The content of '{}' has changed since the checkpoint was created.",
605 path
606 );
607 let commands = if path.contains("PROMPT.md") {
608 vec![
609 format!("# Review the changes to ensure requirements are still correct"),
610 format!("git diff -- {}", path),
611 format!(""),
612 format!("# If changes are incorrect, revert:"),
613 format!("git checkout HEAD -- {}", path),
614 format!(""),
615 format!("# If changes are correct and intentional, use --recovery-strategy=force"),
616 ]
617 } else {
618 vec![
619 format!("# Review the changes"),
620 format!("git diff -- {}", path),
621 format!(""),
622 format!("# If changes are incorrect, revert:"),
623 format!("git checkout HEAD -- {}", path),
624 format!(""),
625 format!("# Or stash current changes and restore from checkpoint"),
626 format!("git stash"),
627 ]
628 };
629 (problem, commands)
630 }
631 Self::GitHeadChanged { expected, actual } => {
632 let problem = format!("Git HEAD has changed from {} to {}. New commits may have been made or HEAD was reset.", expected, actual);
633 let commands = vec![
634 format!("# View the commits that were made after checkpoint"),
635 format!("git log {}..HEAD --oneline", expected),
636 format!(""),
637 format!("# Option 1: Reset to checkpoint state"),
638 format!("git reset {}", expected),
639 format!(""),
640 format!("# Option 2: Accept new state and delete checkpoint"),
641 format!("rm .agent/checkpoint.json"),
642 format!(""),
643 format!("# Option 3: Use --recovery-strategy=force to proceed anyway (risky)"),
644 ];
645 (problem, commands)
646 }
647 Self::GitStateInvalid { reason } => {
648 let problem = format!("Git state is invalid: {}", reason);
649 let commands = if reason.contains("detached") {
650 vec![
651 format!("# View current branch situation"),
652 format!("git branch -a"),
653 format!(""),
654 format!("# Reattach to a branch"),
655 format!("git checkout <branch-name>"),
656 format!(""),
657 format!("# Or list recent commits to choose from"),
658 format!("git log --oneline -10"),
659 ]
660 } else if reason.contains("merge") || reason.contains("rebase") {
661 vec![
662 format!("# Check current git status"),
663 format!("git status"),
664 format!(""),
665 format!("# Option 1: Continue the operation"),
666 format!("# (resolve conflicts, then git add/rm && git continue)"),
667 format!(""),
668 format!("# Option 2: Abort the operation"),
669 format!("git merge --abort # or 'git rebase --abort'"),
670 format!(""),
671 format!("# Option 3: Delete checkpoint and start fresh"),
672 format!("rm .agent/checkpoint.json"),
673 ]
674 } else {
675 vec![
676 format!("# Check current git status"),
677 format!("git status"),
678 format!(""),
679 format!("# Fix the reported issue or delete checkpoint to start fresh"),
680 format!("rm .agent/checkpoint.json"),
681 ]
682 };
683 (problem, commands)
684 }
685 Self::GitWorkingTreeChanged { changes } => {
686 let problem = format!("Git working tree has uncommitted changes: {}", changes);
687 let commands = vec![
688 format!("# View what changed"),
689 format!("git status"),
690 format!("git diff"),
691 format!(""),
692 format!("# Option 1: Commit the changes"),
693 format!("git add -A && git commit -m 'Save work before resume'"),
694 format!(""),
695 format!("# Option 2: Stash the changes"),
696 format!("git stash push -m 'Work saved before resume'"),
697 format!(""),
698 format!("# Option 3: Discard the changes"),
699 format!("git reset --hard HEAD"),
700 format!(""),
701 format!("# Option 4: Use --recovery-strategy=force to proceed anyway"),
702 ];
703 (problem, commands)
704 }
705 }
706 }
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 #[cfg(feature = "test-utils")]
718 mod workspace_tests {
719 use super::*;
720 use crate::workspace::MemoryWorkspace;
721
722 #[test]
723 fn test_file_system_state_new() {
724 let state = FileSystemState::new();
725 assert!(state.files.is_empty());
726 assert!(state.git_head_oid.is_none());
727 assert!(state.git_branch.is_none());
728 }
729
730 #[test]
731 fn test_capture_file_with_workspace() {
732 let workspace = MemoryWorkspace::new_test().with_file("test.txt", "content");
733
734 let mut state = FileSystemState::new();
735 state.capture_file_with_workspace(&workspace, "test.txt");
736
737 assert!(state.files.contains_key("test.txt"));
738 let snapshot = &state.files["test.txt"];
739 assert!(snapshot.exists);
740 assert_eq!(snapshot.size, 7);
741 }
742
743 #[test]
744 fn test_capture_file_with_workspace_nonexistent() {
745 let workspace = MemoryWorkspace::new_test();
746
747 let mut state = FileSystemState::new();
748 state.capture_file_with_workspace(&workspace, "nonexistent.txt");
749
750 assert!(state.files.contains_key("nonexistent.txt"));
751 let snapshot = &state.files["nonexistent.txt"];
752 assert!(!snapshot.exists);
753 assert_eq!(snapshot.size, 0);
754 }
755
756 #[test]
757 fn test_validate_with_workspace_success() {
758 let workspace = MemoryWorkspace::new_test().with_file("test.txt", "content");
759
760 let mut state = FileSystemState::new();
761 state.capture_file_with_workspace(&workspace, "test.txt");
762
763 let errors = state.validate_with_workspace(&workspace, None);
764 assert!(errors.is_empty());
765 }
766
767 #[test]
768 fn test_validate_with_workspace_file_missing() {
769 let workspace_with_file = MemoryWorkspace::new_test().with_file("test.txt", "content");
771 let mut state = FileSystemState::new();
772 state.capture_file_with_workspace(&workspace_with_file, "test.txt");
773
774 let workspace_without_file = MemoryWorkspace::new_test();
776
777 let errors = state.validate_with_workspace(&workspace_without_file, None);
779 assert!(!errors.is_empty());
780 assert!(matches!(errors[0], ValidationError::FileMissing { .. }));
781 }
782
783 #[test]
784 fn test_validate_with_workspace_file_changed() {
785 let workspace_original = MemoryWorkspace::new_test().with_file("test.txt", "content");
787 let mut state = FileSystemState::new();
788 state.capture_file_with_workspace(&workspace_original, "test.txt");
789
790 let workspace_modified = MemoryWorkspace::new_test().with_file("test.txt", "modified");
792
793 let errors = state.validate_with_workspace(&workspace_modified, None);
794 assert!(!errors.is_empty());
795 assert!(matches!(
796 errors[0],
797 ValidationError::FileContentChanged { .. }
798 ));
799 }
800
801 #[test]
802 fn test_validate_with_workspace_file_unexpectedly_exists() {
803 let workspace_empty = MemoryWorkspace::new_test();
805 let mut state = FileSystemState::new();
806 state.capture_file_with_workspace(&workspace_empty, "test.txt");
807
808 let workspace_with_file = MemoryWorkspace::new_test().with_file("test.txt", "content");
810
811 let errors = state.validate_with_workspace(&workspace_with_file, None);
812 assert!(!errors.is_empty());
813 assert!(matches!(
814 errors[0],
815 ValidationError::FileUnexpectedlyExists { .. }
816 ));
817 }
818 }
819
820 #[test]
825 fn test_validation_error_display() {
826 let err = ValidationError::FileMissing {
827 path: "test.txt".to_string(),
828 };
829 assert_eq!(err.to_string(), "File missing: test.txt");
830
831 let err = ValidationError::FileContentChanged {
832 path: "test.txt".to_string(),
833 };
834 assert_eq!(err.to_string(), "File content changed: test.txt");
835 }
836
837 #[test]
838 fn test_validation_error_recovery_suggestion() {
839 let err = ValidationError::FileMissing {
840 path: "test.txt".to_string(),
841 };
842 let (problem, commands) = err.recovery_commands();
843 assert!(problem.contains("test.txt"));
844 assert!(!commands.is_empty());
845
846 let err = ValidationError::GitHeadChanged {
847 expected: "abc123".to_string(),
848 actual: "def456".to_string(),
849 };
850 let (problem, commands) = err.recovery_commands();
851 assert!(problem.contains("abc123"));
852 assert!(commands.iter().any(|c| c.contains("git reset")));
853 }
854
855 #[test]
856 fn test_validation_error_recovery_commands_file_missing() {
857 let err = ValidationError::FileMissing {
858 path: "PROMPT.md".to_string(),
859 };
860 let (problem, commands) = err.recovery_commands();
861
862 assert!(problem.contains("missing"));
863 assert!(problem.contains("PROMPT.md"));
864 assert!(!commands.is_empty());
865 assert!(commands.iter().any(|c| c.contains("find")));
866 }
867
868 #[test]
869 fn test_validation_error_recovery_commands_git_head_changed() {
870 let err = ValidationError::GitHeadChanged {
871 expected: "abc123".to_string(),
872 actual: "def456".to_string(),
873 };
874 let (problem, commands) = err.recovery_commands();
875
876 assert!(problem.contains("changed"));
877 assert!(problem.contains("abc123"));
878 assert!(problem.contains("def456"));
879 assert!(!commands.is_empty());
880 assert!(commands.iter().any(|c| c.contains("git reset")));
881 assert!(commands.iter().any(|c| c.contains("git log")));
882 }
883
884 #[test]
885 fn test_validation_error_recovery_commands_working_tree_changed() {
886 let err = ValidationError::GitWorkingTreeChanged {
887 changes: "M file1.txt\nM file2.txt".to_string(),
888 };
889 let (problem, commands) = err.recovery_commands();
890
891 assert!(problem.contains("uncommitted changes"));
892 assert!(!commands.is_empty());
893 assert!(commands.iter().any(|c| c.contains("git status")));
894 assert!(commands.iter().any(|c| c.contains("git stash")));
895 assert!(commands.iter().any(|c| c.contains("git commit")));
896 }
897
898 #[test]
899 fn test_validation_error_recovery_commands_git_state_invalid() {
900 let err = ValidationError::GitStateInvalid {
901 reason: "detached HEAD state".to_string(),
902 };
903 let (problem, commands) = err.recovery_commands();
904
905 assert!(problem.contains("detached HEAD state"));
906 assert!(!commands.is_empty());
907 assert!(commands.iter().any(|c| c.contains("git checkout")));
908 }
909
910 #[test]
911 fn test_validation_error_recovery_commands_file_content_changed() {
912 let err = ValidationError::FileContentChanged {
913 path: "PROMPT.md".to_string(),
914 };
915 let (problem, commands) = err.recovery_commands();
916
917 assert!(problem.contains("changed"));
918 assert!(problem.contains("PROMPT.md"));
919 assert!(!commands.is_empty());
920 assert!(commands.iter().any(|c| c.contains("git diff")));
921 }
922}