ralph_workflow/checkpoint/
file_state.rs

1//! File system state capture and validation for checkpoints.
2//!
3//! This module provides functionality for capturing and validating the state
4//! of key files in the repository to enable idempotent recovery.
5
6use crate::checkpoint::execution_history::FileSnapshot;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11/// File system state snapshot for key files.
12///
13/// Captures the state of important files that affect pipeline execution.
14/// This enables validation on resume to detect unexpected changes.
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct FileSystemState {
17    /// Snapshots of tracked files
18    pub files: HashMap<String, FileSnapshot>,
19    /// Git HEAD commit OID (if available)
20    pub git_head_oid: Option<String>,
21    /// Git branch name (if available)
22    pub git_branch: Option<String>,
23    /// Git status output (porcelain format) for tracking staged/unstaged changes
24    pub git_status: Option<String>,
25    /// List of modified files from git diff
26    pub git_modified_files: Option<Vec<String>>,
27}
28
29impl FileSystemState {
30    /// Create a new file system state.
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Capture the current state of key files.
36    ///
37    /// This includes files that are critical for pipeline execution:
38    /// - PROMPT.md: The primary task description
39    /// - .agent/PLAN.md: The implementation plan (if exists)
40    /// - .agent/ISSUES.md: Review findings (if exists)
41    /// - .agent/config.toml: Agent configuration (if exists)
42    /// - .agent/start_commit: Baseline commit reference (if exists)
43    /// - .agent/NOTES.md: Development notes (if exists)
44    /// - .agent/status: Pipeline status file (if exists)
45    pub fn capture_current() -> Self {
46        let mut state = Self::new();
47
48        // Always capture PROMPT.md
49        state.capture_file("PROMPT.md");
50
51        // Capture .agent/PLAN.md if it exists (moved to .agent directory)
52        if Path::new(".agent/PLAN.md").exists() {
53            state.capture_file(".agent/PLAN.md");
54        }
55
56        // Capture .agent/ISSUES.md if it exists (moved to .agent directory)
57        if Path::new(".agent/ISSUES.md").exists() {
58            state.capture_file(".agent/ISSUES.md");
59        }
60
61        // Capture .agent/config.toml if it exists
62        if Path::new(".agent/config.toml").exists() {
63            state.capture_file(".agent/config.toml");
64        }
65
66        // Capture .agent/start_commit if it exists
67        if Path::new(".agent/start_commit").exists() {
68            state.capture_file(".agent/start_commit");
69        }
70
71        // Capture .agent/NOTES.md if it exists
72        if Path::new(".agent/NOTES.md").exists() {
73            state.capture_file(".agent/NOTES.md");
74        }
75
76        // Capture .agent/status if it exists
77        if Path::new(".agent/status").exists() {
78            state.capture_file(".agent/status");
79        }
80
81        // Try to capture git state
82        state.capture_git_state();
83
84        state
85    }
86
87    /// Capture a single file's state.
88    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    /// Capture git HEAD state and working tree status.
106    fn capture_git_state(&mut self) {
107        // Try to get HEAD OID
108        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        // Try to get branch name
119        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        // Capture git status --porcelain for tracking staged/unstaged changes
132        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        // Capture list of modified files from git diff
145        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    /// Validate the current file system state against this snapshot.
164    ///
165    /// Returns a list of validation errors. Empty list means all checks passed.
166    pub fn validate(&self) -> Vec<ValidationError> {
167        let mut errors = Vec::new();
168
169        // Validate each tracked file
170        for (path, snapshot) in &self.files {
171            if let Err(e) = self.validate_file(path, snapshot) {
172                errors.push(e);
173            }
174        }
175
176        // Validate git state if we captured it
177        if let Err(e) = self.validate_git_state() {
178            errors.push(e);
179        }
180
181        errors
182    }
183
184    /// Validate a single file against its snapshot.
185    fn validate_file(&self, path: &str, snapshot: &FileSnapshot) -> Result<(), ValidationError> {
186        let path_obj = Path::new(path);
187
188        // Check existence
189        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        // Verify checksum for existing files
202        if snapshot.exists && !snapshot.verify() {
203            return Err(ValidationError::FileContentChanged {
204                path: path.to_string(),
205            });
206        }
207
208        Ok(())
209    }
210
211    /// Validate git state against the snapshot.
212    fn validate_git_state(&self) -> Result<(), ValidationError> {
213        // Validate HEAD OID if we captured it
214        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/// Validation errors for file system state.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237pub enum ValidationError {
238    /// A file that should exist is missing
239    FileMissing { path: String },
240
241    /// A file that shouldn't exist unexpectedly exists
242    FileUnexpectedlyExists { path: String },
243
244    /// A file's content has changed
245    FileContentChanged { path: String },
246
247    /// Git HEAD has changed
248    GitHeadChanged { expected: String, actual: String },
249
250    /// Git working tree has changes (files modified, staged, etc.)
251    GitWorkingTreeChanged { changes: String },
252
253    /// Git state is invalid
254    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
284/// Recovery suggestion for a validation error.
285impl ValidationError {
286    /// Get a structured recovery guide with "What's wrong" and "How to fix" sections.
287    ///
288    /// Returns a tuple of (problem_description, recovery_commands) where:
289    /// - problem_description explains what the issue is
290    /// - recovery_commands is a vector of suggested commands to fix it
291    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            // Create a file and capture its state
505            fs::write("test.txt", "content").unwrap();
506            let mut state = FileSystemState::new();
507            state.capture_file("test.txt");
508
509            // Now delete the file
510            fs::remove_file("test.txt").unwrap();
511
512            // Validation should fail because file is missing
513            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            // Modify the file
528            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}