1use crate::checkpoint::execution_history::FileSnapshot;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct FileSystemState {
17 pub files: HashMap<String, FileSnapshot>,
19 pub git_head_oid: Option<String>,
21 pub git_branch: Option<String>,
23 pub git_status: Option<String>,
25 pub git_modified_files: Option<Vec<String>>,
27}
28
29impl FileSystemState {
30 pub fn new() -> Self {
32 Self::default()
33 }
34
35 pub fn capture_current() -> Self {
46 let mut state = Self::new();
47
48 state.capture_file("PROMPT.md");
50
51 if Path::new(".agent/PLAN.md").exists() {
53 state.capture_file(".agent/PLAN.md");
54 }
55
56 if Path::new(".agent/ISSUES.md").exists() {
58 state.capture_file(".agent/ISSUES.md");
59 }
60
61 if Path::new(".agent/config.toml").exists() {
63 state.capture_file(".agent/config.toml");
64 }
65
66 if Path::new(".agent/start_commit").exists() {
68 state.capture_file(".agent/start_commit");
69 }
70
71 if Path::new(".agent/NOTES.md").exists() {
73 state.capture_file(".agent/NOTES.md");
74 }
75
76 if Path::new(".agent/status").exists() {
78 state.capture_file(".agent/status");
79 }
80
81 state.capture_git_state();
83
84 state
85 }
86
87 pub fn capture_file(&mut self, path: &str) {
89 let path_obj = Path::new(path);
90 let snapshot = if path_obj.exists() {
91 if let Some(checksum) = crate::checkpoint::state::calculate_file_checksum(path_obj) {
92 let metadata = std::fs::metadata(path_obj);
93 let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
94 FileSnapshot::new(path, checksum, size, true)
95 } else {
96 FileSnapshot::not_found(path)
97 }
98 } else {
99 FileSnapshot::not_found(path)
100 };
101
102 self.files.insert(path.to_string(), snapshot);
103 }
104
105 fn capture_git_state(&mut self) {
107 if let Ok(output) = std::process::Command::new("git")
109 .args(["rev-parse", "HEAD"])
110 .output()
111 {
112 if output.status.success() {
113 let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
114 self.git_head_oid = Some(oid);
115 }
116 }
117
118 if let Ok(output) = std::process::Command::new("git")
120 .args(["rev-parse", "--abbrev-ref", "HEAD"])
121 .output()
122 {
123 if output.status.success() {
124 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
125 if !branch.is_empty() && branch != "HEAD" {
126 self.git_branch = Some(branch);
127 }
128 }
129 }
130
131 if let Ok(output) = std::process::Command::new("git")
133 .args(["status", "--porcelain"])
134 .output()
135 {
136 if output.status.success() {
137 let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
138 if !status.is_empty() {
139 self.git_status = Some(status);
140 }
141 }
142 }
143
144 if let Ok(output) = std::process::Command::new("git")
146 .args(["diff", "--name-only"])
147 .output()
148 {
149 if output.status.success() {
150 let diff_output = String::from_utf8_lossy(&output.stdout);
151 let modified_files: Vec<String> = diff_output
152 .lines()
153 .map(|line| line.trim().to_string())
154 .filter(|line| !line.is_empty())
155 .collect();
156 if !modified_files.is_empty() {
157 self.git_modified_files = Some(modified_files);
158 }
159 }
160 }
161 }
162
163 pub fn validate(&self) -> Vec<ValidationError> {
167 let mut errors = Vec::new();
168
169 for (path, snapshot) in &self.files {
171 if let Err(e) = self.validate_file(path, snapshot) {
172 errors.push(e);
173 }
174 }
175
176 if let Err(e) = self.validate_git_state() {
178 errors.push(e);
179 }
180
181 errors
182 }
183
184 fn validate_file(&self, path: &str, snapshot: &FileSnapshot) -> Result<(), ValidationError> {
186 let path_obj = Path::new(path);
187
188 if snapshot.exists && !path_obj.exists() {
190 return Err(ValidationError::FileMissing {
191 path: path.to_string(),
192 });
193 }
194
195 if !snapshot.exists && path_obj.exists() {
196 return Err(ValidationError::FileUnexpectedlyExists {
197 path: path.to_string(),
198 });
199 }
200
201 if snapshot.exists && !snapshot.verify() {
203 return Err(ValidationError::FileContentChanged {
204 path: path.to_string(),
205 });
206 }
207
208 Ok(())
209 }
210
211 fn validate_git_state(&self) -> Result<(), ValidationError> {
213 if let Some(expected_oid) = &self.git_head_oid {
215 if let Ok(output) = std::process::Command::new("git")
216 .args(["rev-parse", "HEAD"])
217 .output()
218 {
219 if output.status.success() {
220 let current_oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
221 if current_oid != *expected_oid {
222 return Err(ValidationError::GitHeadChanged {
223 expected: expected_oid.clone(),
224 actual: current_oid,
225 });
226 }
227 }
228 }
229 }
230
231 Ok(())
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237pub enum ValidationError {
238 FileMissing { path: String },
240
241 FileUnexpectedlyExists { path: String },
243
244 FileContentChanged { path: String },
246
247 GitHeadChanged { expected: String, actual: String },
249
250 GitWorkingTreeChanged { changes: String },
252
253 GitStateInvalid { reason: String },
255}
256
257impl std::fmt::Display for ValidationError {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 match self {
260 Self::FileMissing { path } => {
261 write!(f, "File missing: {}", path)
262 }
263 Self::FileUnexpectedlyExists { path } => {
264 write!(f, "File unexpectedly exists: {}", path)
265 }
266 Self::FileContentChanged { path } => {
267 write!(f, "File content changed: {}", path)
268 }
269 Self::GitHeadChanged { expected, actual } => {
270 write!(f, "Git HEAD changed: expected {}, got {}", expected, actual)
271 }
272 Self::GitWorkingTreeChanged { changes } => {
273 write!(f, "Git working tree changed: {}", changes)
274 }
275 Self::GitStateInvalid { reason } => {
276 write!(f, "Git state invalid: {}", reason)
277 }
278 }
279 }
280}
281
282impl std::error::Error for ValidationError {}
283
284impl ValidationError {
286 pub fn recovery_commands(&self) -> (String, Vec<String>) {
292 match self {
293 Self::FileMissing { path } => {
294 let problem = format!(
295 "The file '{}' is missing but was present when the checkpoint was created.",
296 path
297 );
298 let commands = if path.contains("PROMPT.md") {
299 vec![
300 format!("# Check if file exists elsewhere"),
301 format!("find . -name 'PROMPT.md' -type f 2>/dev/null"),
302 format!(""),
303 format!("# Or recreate from requirements"),
304 format!("# Restore from backup or recreate PROMPT.md"),
305 format!(""),
306 format!("# If unrecoverable, delete checkpoint to start fresh"),
307 format!("rm .agent/checkpoint.json"),
308 ]
309 } else if path.contains(".agent/") {
310 vec![
311 format!("# Agent files should be restored from checkpoint if available"),
312 format!(""),
313 format!("# Or delete checkpoint to start fresh"),
314 format!("rm .agent/checkpoint.json"),
315 ]
316 } else {
317 vec![
318 format!("# Restore from backup or recreate"),
319 format!("git checkout HEAD -- {}", path),
320 format!(""),
321 format!("# Or if unrecoverable, delete checkpoint"),
322 format!("rm .agent/checkpoint.json"),
323 ]
324 };
325 (problem, commands)
326 }
327 Self::FileUnexpectedlyExists { path } => {
328 let problem = format!("The file '{}' should not exist but was found.", path);
329 let commands = vec![
330 format!("# Review the file to see if it should be kept"),
331 format!("cat {}", path),
332 format!(""),
333 format!("# If it should be removed:"),
334 format!("rm {}", path),
335 format!(""),
336 format!("# Or if it should be kept, delete the checkpoint to start fresh"),
337 format!("rm .agent/checkpoint.json"),
338 ];
339 (problem, commands)
340 }
341 Self::FileContentChanged { path } => {
342 let problem = format!(
343 "The content of '{}' has changed since the checkpoint was created.",
344 path
345 );
346 let commands = if path.contains("PROMPT.md") {
347 vec![
348 format!("# Review the changes to ensure requirements are still correct"),
349 format!("git diff -- {}", path),
350 format!(""),
351 format!("# If changes are incorrect, revert:"),
352 format!("git checkout HEAD -- {}", path),
353 format!(""),
354 format!("# If changes are correct and intentional, use --recovery-strategy=force"),
355 ]
356 } else {
357 vec![
358 format!("# Review the changes"),
359 format!("git diff -- {}", path),
360 format!(""),
361 format!("# If changes are incorrect, revert:"),
362 format!("git checkout HEAD -- {}", path),
363 format!(""),
364 format!("# Or stash current changes and restore from checkpoint"),
365 format!("git stash"),
366 ]
367 };
368 (problem, commands)
369 }
370 Self::GitHeadChanged { expected, actual } => {
371 let problem = format!("Git HEAD has changed from {} to {}. New commits may have been made or HEAD was reset.", expected, actual);
372 let commands = vec![
373 format!("# View the commits that were made after checkpoint"),
374 format!("git log {}..HEAD --oneline", expected),
375 format!(""),
376 format!("# Option 1: Reset to checkpoint state"),
377 format!("git reset {}", expected),
378 format!(""),
379 format!("# Option 2: Accept new state and delete checkpoint"),
380 format!("rm .agent/checkpoint.json"),
381 format!(""),
382 format!("# Option 3: Use --recovery-strategy=force to proceed anyway (risky)"),
383 ];
384 (problem, commands)
385 }
386 Self::GitStateInvalid { reason } => {
387 let problem = format!("Git state is invalid: {}", reason);
388 let commands = if reason.contains("detached") {
389 vec![
390 format!("# View current branch situation"),
391 format!("git branch -a"),
392 format!(""),
393 format!("# Reattach to a branch"),
394 format!("git checkout <branch-name>"),
395 format!(""),
396 format!("# Or list recent commits to choose from"),
397 format!("git log --oneline -10"),
398 ]
399 } else if reason.contains("merge") || reason.contains("rebase") {
400 vec![
401 format!("# Check current git status"),
402 format!("git status"),
403 format!(""),
404 format!("# Option 1: Continue the operation"),
405 format!("# (resolve conflicts, then git add/rm && git continue)"),
406 format!(""),
407 format!("# Option 2: Abort the operation"),
408 format!("git merge --abort # or 'git rebase --abort'"),
409 format!(""),
410 format!("# Option 3: Delete checkpoint and start fresh"),
411 format!("rm .agent/checkpoint.json"),
412 ]
413 } else {
414 vec![
415 format!("# Check current git status"),
416 format!("git status"),
417 format!(""),
418 format!("# Fix the reported issue or delete checkpoint to start fresh"),
419 format!("rm .agent/checkpoint.json"),
420 ]
421 };
422 (problem, commands)
423 }
424 Self::GitWorkingTreeChanged { changes } => {
425 let problem = format!("Git working tree has uncommitted changes: {}", changes);
426 let commands = vec![
427 format!("# View what changed"),
428 format!("git status"),
429 format!("git diff"),
430 format!(""),
431 format!("# Option 1: Commit the changes"),
432 format!("git add -A && git commit -m 'Save work before resume'"),
433 format!(""),
434 format!("# Option 2: Stash the changes"),
435 format!("git stash push -m 'Work saved before resume'"),
436 format!(""),
437 format!("# Option 3: Discard the changes"),
438 format!("git reset --hard HEAD"),
439 format!(""),
440 format!("# Option 4: Use --recovery-strategy=force to proceed anyway"),
441 ];
442 (problem, commands)
443 }
444 }
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use std::fs;
452 use test_helpers::with_temp_cwd;
453
454 #[test]
455 fn test_file_system_state_new() {
456 let state = FileSystemState::new();
457 assert!(state.files.is_empty());
458 assert!(state.git_head_oid.is_none());
459 assert!(state.git_branch.is_none());
460 }
461
462 #[test]
463 fn test_file_system_state_capture_file() {
464 with_temp_cwd(|_dir| {
465 fs::write("test.txt", "content").unwrap();
466
467 let mut state = FileSystemState::new();
468 state.capture_file("test.txt");
469
470 assert!(state.files.contains_key("test.txt"));
471 let snapshot = &state.files["test.txt"];
472 assert!(snapshot.exists);
473 assert_eq!(snapshot.size, 7);
474 });
475 }
476
477 #[test]
478 fn test_file_system_state_capture_nonexistent() {
479 let mut state = FileSystemState::new();
480 state.capture_file("nonexistent.txt");
481
482 assert!(state.files.contains_key("nonexistent.txt"));
483 let snapshot = &state.files["nonexistent.txt"];
484 assert!(!snapshot.exists);
485 assert_eq!(snapshot.size, 0);
486 }
487
488 #[test]
489 fn test_file_system_state_validate_success() {
490 with_temp_cwd(|_dir| {
491 fs::write("test.txt", "content").unwrap();
492
493 let mut state = FileSystemState::new();
494 state.capture_file("test.txt");
495
496 let errors = state.validate();
497 assert!(errors.is_empty());
498 });
499 }
500
501 #[test]
502 fn test_file_system_state_validate_file_missing() {
503 with_temp_cwd(|_dir| {
504 fs::write("test.txt", "content").unwrap();
506 let mut state = FileSystemState::new();
507 state.capture_file("test.txt");
508
509 fs::remove_file("test.txt").unwrap();
511
512 let errors = state.validate();
514 assert!(!errors.is_empty());
515 assert!(matches!(errors[0], ValidationError::FileMissing { .. }));
516 });
517 }
518
519 #[test]
520 fn test_file_system_state_validate_file_changed() {
521 with_temp_cwd(|_dir| {
522 fs::write("test.txt", "content").unwrap();
523
524 let mut state = FileSystemState::new();
525 state.capture_file("test.txt");
526
527 fs::write("test.txt", "modified").unwrap();
529
530 let errors = state.validate();
531 assert!(!errors.is_empty());
532 assert!(matches!(
533 errors[0],
534 ValidationError::FileContentChanged { .. }
535 ));
536 });
537 }
538
539 #[test]
540 fn test_validation_error_display() {
541 let err = ValidationError::FileMissing {
542 path: "test.txt".to_string(),
543 };
544 assert_eq!(err.to_string(), "File missing: test.txt");
545
546 let err = ValidationError::FileContentChanged {
547 path: "test.txt".to_string(),
548 };
549 assert_eq!(err.to_string(), "File content changed: test.txt");
550 }
551
552 #[test]
553 fn test_validation_error_recovery_suggestion() {
554 let err = ValidationError::FileMissing {
555 path: "test.txt".to_string(),
556 };
557 let (problem, commands) = err.recovery_commands();
558 assert!(problem.contains("test.txt"));
559 assert!(!commands.is_empty());
560
561 let err = ValidationError::GitHeadChanged {
562 expected: "abc123".to_string(),
563 actual: "def456".to_string(),
564 };
565 let (problem, commands) = err.recovery_commands();
566 assert!(problem.contains("abc123"));
567 assert!(commands.iter().any(|c| c.contains("git reset")));
568 }
569
570 #[test]
571 fn test_validation_error_recovery_commands_file_missing() {
572 let err = ValidationError::FileMissing {
573 path: "PROMPT.md".to_string(),
574 };
575 let (problem, commands) = err.recovery_commands();
576
577 assert!(problem.contains("missing"));
578 assert!(problem.contains("PROMPT.md"));
579 assert!(!commands.is_empty());
580 assert!(commands.iter().any(|c| c.contains("find")));
581 }
582
583 #[test]
584 fn test_validation_error_recovery_commands_git_head_changed() {
585 let err = ValidationError::GitHeadChanged {
586 expected: "abc123".to_string(),
587 actual: "def456".to_string(),
588 };
589 let (problem, commands) = err.recovery_commands();
590
591 assert!(problem.contains("changed"));
592 assert!(problem.contains("abc123"));
593 assert!(problem.contains("def456"));
594 assert!(!commands.is_empty());
595 assert!(commands.iter().any(|c| c.contains("git reset")));
596 assert!(commands.iter().any(|c| c.contains("git log")));
597 }
598
599 #[test]
600 fn test_validation_error_recovery_commands_working_tree_changed() {
601 let err = ValidationError::GitWorkingTreeChanged {
602 changes: "M file1.txt\nM file2.txt".to_string(),
603 };
604 let (problem, commands) = err.recovery_commands();
605
606 assert!(problem.contains("uncommitted changes"));
607 assert!(!commands.is_empty());
608 assert!(commands.iter().any(|c| c.contains("git status")));
609 assert!(commands.iter().any(|c| c.contains("git stash")));
610 assert!(commands.iter().any(|c| c.contains("git commit")));
611 }
612
613 #[test]
614 fn test_validation_error_recovery_commands_git_state_invalid() {
615 let err = ValidationError::GitStateInvalid {
616 reason: "detached HEAD state".to_string(),
617 };
618 let (problem, commands) = err.recovery_commands();
619
620 assert!(problem.contains("detached HEAD state"));
621 assert!(!commands.is_empty());
622 assert!(commands.iter().any(|c| c.contains("git checkout")));
623 }
624
625 #[test]
626 fn test_validation_error_recovery_commands_file_content_changed() {
627 let err = ValidationError::FileContentChanged {
628 path: "PROMPT.md".to_string(),
629 };
630 let (problem, commands) = err.recovery_commands();
631
632 assert!(problem.contains("changed"));
633 assert!(problem.contains("PROMPT.md"));
634 assert!(!commands.is_empty());
635 assert!(commands.iter().any(|c| c.contains("git diff")));
636 }
637}