Skip to main content

ralph_workflow/app/resume/
validation.rs

1// Checkpoint validation logic for resume functionality.
2// This module handles verifying checkpoint integrity and file system state validation.
3
4/// Result of file system validation.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum ValidationOutcome {
7    /// Validation passed, safe to resume
8    Passed,
9    /// Validation failed, cannot resume
10    Failed(String),
11}
12
13/// Validate file system state when resuming.
14///
15/// This function validates that the current file system state matches
16/// the state captured in the checkpoint. This is part of the hardened
17/// resume feature that ensures idempotent recovery.
18///
19/// Returns a `ValidationOutcome` indicating whether validation passed
20/// or failed with a reason.
21pub(crate) fn validate_file_system_state(
22    file_system_state: &FileSystemState,
23    logger: &Logger,
24    strategy: crate::checkpoint::recovery::RecoveryStrategy,
25    workspace: &dyn Workspace,
26) -> ValidationOutcome {
27    let errors = file_system_state.validate_with_workspace(workspace, None);
28
29    if errors.is_empty() {
30        logger.info("File system state validation passed.");
31        return ValidationOutcome::Passed;
32    }
33
34    logger.warn("File system state validation detected changes:");
35
36    for error in &errors {
37        let (problem, commands) = error.recovery_commands();
38        logger.warn(&format!("  - {}", error));
39        logger.info(&format!("    What's wrong: {}", problem));
40        logger.info("    How to fix:");
41        for cmd in commands {
42            logger.info(&format!("      {}", cmd));
43        }
44    }
45
46    // Handle based on the recovery strategy
47    match strategy {
48        crate::checkpoint::recovery::RecoveryStrategy::Fail => {
49            logger.error("File system state validation failed (strategy: fail).");
50            logger.info("Use --recovery-strategy=auto to attempt automatic recovery.");
51            logger.info("Use --recovery-strategy=force to proceed anyway (not recommended).");
52            ValidationOutcome::Failed(
53                "File system state changed - see errors above or use --recovery-strategy=force to proceed anyway".to_string()
54            )
55        }
56        crate::checkpoint::recovery::RecoveryStrategy::Force => {
57            logger.warn("Proceeding with resume despite file changes (strategy: force).");
58            logger.info("Note: Pipeline behavior may be unpredictable.");
59            ValidationOutcome::Passed
60        }
61        crate::checkpoint::recovery::RecoveryStrategy::Auto => {
62            // Attempt automatic recovery for recoverable errors
63            let (_recovered, remaining) =
64                attempt_auto_recovery(file_system_state, &errors, logger, workspace);
65
66            if remaining.is_empty() {
67                logger.success("Automatic recovery completed successfully.");
68                ValidationOutcome::Passed
69            } else {
70                logger.warn("Some issues could not be automatically recovered:");
71                for error in &remaining {
72                    logger.warn(&format!("  - {}", error));
73                }
74                logger.warn("Proceeding with resume despite unrecovered issues (strategy: auto).");
75                logger.info("Note: Pipeline behavior may be unpredictable.");
76                ValidationOutcome::Passed
77            }
78        }
79    }
80}
81
82/// Attempt automatic recovery from file system state changes.
83///
84/// This function attempts to automatically fix recoverable issues:
85/// - Restores small files from content stored in snapshot
86/// - Warns about unrecoverable issues (large files, git changes)
87///
88/// # Arguments
89///
90/// * `file_system_state` - The file system state from checkpoint
91/// * `errors` - Validation errors that were detected
92/// * `logger` - Logger for output
93///
94/// # Returns
95///
96/// A tuple of (number of issues recovered, remaining errors)
97fn attempt_auto_recovery(
98    file_system_state: &FileSystemState,
99    errors: &[ValidationError],
100    logger: &Logger,
101    workspace: &dyn Workspace,
102) -> (usize, Vec<ValidationError>) {
103    let mut recovered = 0;
104    let mut remaining = Vec::new();
105
106    for error in errors {
107        match attempt_recovery_for_error(file_system_state, error, logger, workspace) {
108            Ok(()) => {
109                recovered += 1;
110                logger.success(&format!("Recovered: {}", error));
111            }
112            Err(e) => {
113                remaining.push(error.clone());
114                logger.warn(&format!("Could not recover: {} - {}", error, e));
115            }
116        }
117    }
118
119    (recovered, remaining)
120}
121
122/// Attempt to recover from a single validation error.
123///
124/// # Returns
125///
126/// `Ok(())` if recovery succeeded, `Err(reason)` if it failed.
127fn attempt_recovery_for_error(
128    file_system_state: &FileSystemState,
129    error: &ValidationError,
130    logger: &Logger,
131    workspace: &dyn Workspace,
132) -> Result<(), String> {
133    match error {
134        ValidationError::FileContentChanged { path } => {
135            // Try to restore from snapshot if content is available
136            if let Some(snapshot) = file_system_state.files.get(path) {
137                if let Some(content) = snapshot.get_content() {
138                    workspace
139                        .write(Path::new(path), &content)
140                        .map_err(|e| format!("Failed to write file: {}", e))?;
141                    logger.info(&format!("Restored {} from checkpoint content.", path));
142                    return Ok(());
143                }
144            }
145            Err("No content available in snapshot".to_string())
146        }
147        ValidationError::GitHeadChanged { .. } => {
148            // Git state changes are not automatically recoverable
149            // They require user intervention to reset or accept the new state
150            Err("Git HEAD changes require manual intervention".to_string())
151        }
152        ValidationError::GitStateInvalid { .. } => {
153            Err("Git state validation requires manual intervention".to_string())
154        }
155        ValidationError::GitWorkingTreeChanged { .. } => {
156            // Working tree changes are not automatically recoverable
157            Err("Git working tree changes require manual intervention".to_string())
158        }
159        ValidationError::FileMissing { path } => {
160            // Can't recover a missing file unless we have content
161            if let Some(snapshot) = file_system_state.files.get(path) {
162                if let Some(content) = snapshot.get_content() {
163                    workspace
164                        .write(Path::new(path), &content)
165                        .map_err(|e| format!("Failed to write file: {}", e))?;
166                    logger.info(&format!("Restored missing {} from checkpoint.", path));
167                    return Ok(());
168                }
169            }
170            Err(format!("Cannot recover missing file {}", path))
171        }
172        ValidationError::FileUnexpectedlyExists { path } => {
173            // Unexpected files should be removed by user
174            Err(format!(
175                "File {} should not exist - requires manual removal",
176                path
177            ))
178        }
179    }
180}
181
182/// Check for in-progress git rebase when resuming.
183///
184/// This function detects if a git rebase is in progress and provides
185/// appropriate guidance to the user.
186pub(crate) fn check_rebase_state_on_resume(checkpoint: &PipelineCheckpoint, logger: &Logger) {
187    // Only check for rebase if we're resuming from a rebase-related phase
188    let is_rebase_phase = matches!(
189        checkpoint.phase,
190        PipelinePhase::PreRebase
191            | PipelinePhase::PreRebaseConflict
192            | PipelinePhase::PostRebase
193            | PipelinePhase::PostRebaseConflict
194    );
195
196    if !is_rebase_phase {
197        return;
198    }
199
200    match rebase_in_progress() {
201        Ok(true) => {
202            logger.warn("A git rebase is currently in progress.");
203            logger.info("The checkpoint indicates you were in a rebase phase.");
204            logger.info("Options:");
205            logger.info("  - Continue: Let ralph complete the rebase process");
206            logger.info("  - Abort manually: Run 'git rebase --abort' then use --resume");
207        }
208        Ok(false) => {
209            // No rebase in progress - this is expected if rebase completed
210            // but checkpoint wasn't cleared (e.g., pipeline was interrupted)
211            logger.info("No git rebase is currently in progress.");
212        }
213        Err(e) => {
214            logger.warn(&format!("Could not check rebase state: {e}"));
215        }
216    }
217}