Skip to main content

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 state
4//! of key files in repository to enable idempotent recovery.
5
6use 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/// File system state snapshot for key files.
14///
15/// Captures the state of important files that affect pipeline execution.
16/// This enables validation on resume to detect unexpected changes.
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18pub struct FileSystemState {
19    /// Snapshots of tracked files
20    pub files: HashMap<String, FileSnapshot>,
21    /// Git HEAD commit OID (if available)
22    pub git_head_oid: Option<String>,
23    /// Git branch name (if available)
24    pub git_branch: Option<String>,
25    /// Git status output (porcelain format) for tracking staged/unstaged changes
26    pub git_status: Option<String>,
27    /// List of modified files from git diff
28    pub git_modified_files: Option<Vec<String>>,
29}
30
31impl FileSystemState {
32    /// Create a new file system state.
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Capture the current state with an optional executor.
38    ///
39    /// If executor is None, uses RealProcessExecutor (production default).
40    ///
41    /// # Note
42    ///
43    /// This function requires an explicit executor parameter to enable proper
44    /// dependency injection for testing. For production code, pass
45    /// `Some(&RealProcessExecutor::new())`.
46    ///
47    /// # Deprecated
48    ///
49    /// This function uses CWD-relative paths. Prefer `capture_with_workspace` for new code.
50    #[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        // Use capture_current_with_executor_impl to avoid calling deprecated function
56        match executor {
57            Some(exec) => Self::capture_current_with_executor_impl(exec),
58            None => {
59                // Create a temporary executor and capture the state
60                // This is only used in code paths where no executor is available
61                let real_executor = RealProcessExecutor::new();
62                Self::capture_current_with_executor_impl(&real_executor)
63            }
64        }
65    }
66
67    /// Internal implementation of capture_with_optional_executor (non-deprecated).
68    ///
69    /// This is a crate-internal function that uses CWD-relative paths. It exists to support
70    /// CLI-layer code that operates before a workspace is available. New pipeline code
71    /// should use `capture_with_workspace` instead.
72    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    /// Internal implementation of capture_current_with_executor (non-deprecated).
85    ///
86    /// This is a crate-internal function that uses CWD-relative paths. It exists to support
87    /// CLI-layer code that operates before a workspace is available. New pipeline code
88    /// should use `capture_with_workspace` instead.
89    fn capture_current_with_executor_impl(executor: &dyn ProcessExecutor) -> Self {
90        let mut state = Self::new();
91
92        // Always capture PROMPT.md
93        state.capture_file_impl("PROMPT.md");
94
95        // Capture .agent/PLAN.md if it exists (moved to .agent directory)
96        if Path::new(".agent/PLAN.md").exists() {
97            state.capture_file_impl(".agent/PLAN.md");
98        }
99
100        // Capture .agent/ISSUES.md if it exists (moved to .agent directory)
101        if Path::new(".agent/ISSUES.md").exists() {
102            state.capture_file_impl(".agent/ISSUES.md");
103        }
104
105        // Capture .agent/config.toml if it exists
106        if Path::new(".agent/config.toml").exists() {
107            state.capture_file_impl(".agent/config.toml");
108        }
109
110        // Capture .agent/start_commit if it exists
111        if Path::new(".agent/start_commit").exists() {
112            state.capture_file_impl(".agent/start_commit");
113        }
114
115        // Capture .agent/NOTES.md if it exists
116        if Path::new(".agent/NOTES.md").exists() {
117            state.capture_file_impl(".agent/NOTES.md");
118        }
119
120        // Capture .agent/status if it exists
121        if Path::new(".agent/status").exists() {
122            state.capture_file_impl(".agent/status");
123        }
124
125        // Try to capture git state
126        state.capture_git_state(executor);
127
128        state
129    }
130
131    /// Capture the current state of key files using a workspace.
132    ///
133    /// This includes files that are critical for pipeline execution:
134    /// - PROMPT.md: The primary task description
135    /// - .agent/PLAN.md: The implementation plan (if exists)
136    /// - .agent/ISSUES.md: Review findings (if exists)
137    /// - .agent/config.toml: Agent configuration (if exists)
138    /// - .agent/start_commit: Baseline commit reference (if exists)
139    /// - .agent/NOTES.md: Development notes (if exists)
140    /// - .agent/status: Pipeline status file (if exists)
141    pub fn capture_with_workspace(
142        workspace: &dyn Workspace,
143        executor: &dyn ProcessExecutor,
144    ) -> Self {
145        let mut state = Self::new();
146
147        // Always capture PROMPT.md
148        state.capture_file_with_workspace(workspace, "PROMPT.md");
149
150        // Capture .agent/PLAN.md if it exists
151        if workspace.exists(Path::new(".agent/PLAN.md")) {
152            state.capture_file_with_workspace(workspace, ".agent/PLAN.md");
153        }
154
155        // Capture .agent/ISSUES.md if it exists
156        if workspace.exists(Path::new(".agent/ISSUES.md")) {
157            state.capture_file_with_workspace(workspace, ".agent/ISSUES.md");
158        }
159
160        // Capture .agent/config.toml if it exists
161        if workspace.exists(Path::new(".agent/config.toml")) {
162            state.capture_file_with_workspace(workspace, ".agent/config.toml");
163        }
164
165        // Capture .agent/start_commit if it exists
166        if workspace.exists(Path::new(".agent/start_commit")) {
167            state.capture_file_with_workspace(workspace, ".agent/start_commit");
168        }
169
170        // Capture .agent/NOTES.md if it exists
171        if workspace.exists(Path::new(".agent/NOTES.md")) {
172            state.capture_file_with_workspace(workspace, ".agent/NOTES.md");
173        }
174
175        // Capture .agent/status if it exists
176        if workspace.exists(Path::new(".agent/status")) {
177            state.capture_file_with_workspace(workspace, ".agent/status");
178        }
179
180        // Try to capture git state
181        state.capture_git_state(executor);
182
183        state
184    }
185
186    /// Capture the current state of key files with a provided process executor.
187    ///
188    /// This includes files that are critical for pipeline execution:
189    /// - PROMPT.md: The primary task description
190    /// - .agent/PLAN.md: The implementation plan (if exists)
191    /// - .agent/ISSUES.md: Review findings (if exists)
192    /// - .agent/config.toml: Agent configuration (if exists)
193    /// - .agent/start_commit: Baseline commit reference (if exists)
194    /// - .agent/NOTES.md: Development notes (if exists)
195    /// - .agent/status: Pipeline status file (if exists)
196    ///
197    /// # Deprecated
198    ///
199    /// This function uses CWD-relative paths. Prefer `capture_with_workspace` for new code.
200    #[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    /// Capture a single file's state using a workspace.
209    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    /// Internal implementation of file capture (non-deprecated).
227    ///
228    /// This is the core logic used by both deprecated and non-deprecated variants.
229    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    /// Capture a single file's state.
247    ///
248    /// # Deprecated
249    ///
250    /// This function uses CWD-relative paths. Prefer `capture_file_with_workspace` for new code.
251    #[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    /// Capture git HEAD state and working tree status.
260    fn capture_git_state(&mut self, executor: &dyn ProcessExecutor) {
261        // Try to get HEAD OID
262        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        // Try to get branch name
270        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        // Capture git status --porcelain for tracking staged/unstaged changes
282        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        // Capture list of modified files from git diff
292        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    /// Validate the current file system state against this snapshot.
308    ///
309    /// Returns a list of validation errors. Empty list means all checks passed.
310    ///
311    /// # Deprecated
312    ///
313    /// This function uses CWD-relative paths. Prefer `validate_with_workspace` for new code.
314    #[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    /// Validate the current file system state against this snapshot using a workspace.
323    ///
324    /// Returns a list of validation errors. Empty list means all checks passed.
325    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        // Validate each tracked file
333        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        // Validate git state if we captured it and executor was provided
340        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    /// Validate the current file system state against this snapshot with a provided executor.
350    ///
351    /// Returns a list of validation errors. Empty list means all checks passed.
352    ///
353    /// # Note
354    ///
355    /// This is a crate-internal function that uses CWD-relative paths. It exists to support
356    /// CLI-layer code that operates before a workspace is available. New pipeline code
357    /// should use `validate_with_workspace` instead.
358    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        // Validate each tracked file
365        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        // Validate git state if we captured it and executor was provided
372        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    /// Validate a single file against its snapshot using a workspace.
393    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        // Check existence
402        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        // Verify checksum for existing files
415        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    /// Internal implementation of validate_file (non-deprecated).
425    fn validate_file_impl(
426        &self,
427        path: &str,
428        snapshot: &FileSnapshot,
429    ) -> Result<(), ValidationError> {
430        let path_obj = Path::new(path);
431
432        // Check existence
433        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        // Verify checksum for existing files - use old verify method that reads from CWD
446        // This is deprecated but kept for backward compatibility
447        if snapshot.exists {
448            // Read file and verify checksum manually since we don't have workspace
449            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    /// Validate git state against the snapshot with a provided process executor.
473    fn validate_git_state_with_executor(
474        &self,
475        executor: &dyn ProcessExecutor,
476    ) -> Result<(), ValidationError> {
477        // Validate HEAD OID if we captured it
478        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/// Validation errors for file system state.
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498pub enum ValidationError {
499    /// A file that should exist is missing
500    FileMissing { path: String },
501
502    /// A file that shouldn't exist unexpectedly exists
503    FileUnexpectedlyExists { path: String },
504
505    /// A file's content has changed
506    FileContentChanged { path: String },
507
508    /// Git HEAD has changed
509    GitHeadChanged { expected: String, actual: String },
510
511    /// Git working tree has changes (files modified, staged, etc.)
512    GitWorkingTreeChanged { changes: String },
513
514    /// Git state is invalid
515    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
545/// Recovery suggestion for a validation error.
546impl ValidationError {
547    /// Get a structured recovery guide with "What's wrong" and "How to fix" sections.
548    ///
549    /// Returns a tuple of (problem_description, recovery_commands) where:
550    /// - problem_description explains what the issue is
551    /// - recovery_commands is a vector of suggested commands to fix it
552    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    // =========================================================================
714    // Workspace-based tests (for testability without real filesystem)
715    // =========================================================================
716
717    #[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            // Create workspace with file, capture state
770            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            // Create new workspace without the file (simulating file deletion)
775            let workspace_without_file = MemoryWorkspace::new_test();
776
777            // Validation should fail because file is missing
778            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            // Create workspace with original file
786            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            // Create new workspace with modified content
791            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            // Create state with non-existent file
804            let workspace_empty = MemoryWorkspace::new_test();
805            let mut state = FileSystemState::new();
806            state.capture_file_with_workspace(&workspace_empty, "test.txt");
807
808            // Create new workspace with the file (simulating unexpected file creation)
809            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    // =========================================================================
821    // Pure unit tests (no filesystem access)
822    // =========================================================================
823
824    #[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}