Skip to main content

rec/replay/
engine.rs

1//! Replay engine orchestrating the full replay loop.
2//!
3//! The `ReplayEngine` is a state machine that processes commands sequentially,
4//! applying filters, safety checks, interactive prompts, and execution with
5//! error handling and signal handling.
6
7use std::path::Path;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, Ordering};
10
11use crate::cli::Output;
12use crate::models::Session;
13use crate::models::config::Config;
14use crate::replay::executor;
15use crate::replay::prompt;
16use crate::replay::safety::DestructiveDetector;
17use crate::replay::{DangerPolicy, ReplayOptions, ReplaySummary};
18
19/// Replay engine that orchestrates command filtering, safety checks,
20/// interactive prompts, execution, error handling, and signal handling.
21pub struct ReplayEngine {
22    session: Session,
23    options: ReplayOptions,
24    detector: DestructiveDetector,
25    output: Output,
26    aborted: Arc<AtomicBool>,
27    step_mode_active: bool,
28    executed_count: usize,
29    skipped_count: usize,
30    failed_count: usize,
31    skipped_dangerous: usize,
32    /// Current working directory, updated when cd commands are executed
33    current_cwd: Option<std::path::PathBuf>,
34}
35
36impl ReplayEngine {
37    /// Create a new replay engine.
38    ///
39    /// # Arguments
40    /// - `session` - The session to replay
41    /// - `options` - Replay configuration (dry-run, step, skip, etc.)
42    /// - `config` - Application config (safety settings)
43    /// - `output` - Output handler for styled terminal output
44    #[must_use]
45    pub fn new(session: Session, options: ReplayOptions, config: &Config, output: Output) -> Self {
46        let detector = DestructiveDetector::new(&config.safety);
47        let aborted = Arc::new(AtomicBool::new(false));
48        let step_mode_active = options.step;
49
50        Self {
51            session,
52            options,
53            detector,
54            output,
55            aborted,
56            step_mode_active,
57            executed_count: 0,
58            skipped_count: 0,
59            failed_count: 0,
60            skipped_dangerous: 0,
61            current_cwd: None,
62        }
63    }
64
65    /// Create a new replay engine with a shared abort flag.
66    ///
67    /// Use this when a global Ctrl+C handler is already installed and you
68    /// want the engine to check the same flag.
69    ///
70    /// # Arguments
71    /// - `session` - The session to replay
72    /// - `options` - Replay configuration (dry-run, step, skip, etc.)
73    /// - `config` - Application config (safety settings)
74    /// - `output` - Output handler for styled terminal output
75    /// - `abort_flag` - Shared atomic flag set by the global Ctrl+C handler
76    pub fn with_abort_flag(
77        session: Session,
78        options: ReplayOptions,
79        config: &Config,
80        output: Output,
81        abort_flag: Arc<AtomicBool>,
82    ) -> Self {
83        let detector = DestructiveDetector::new(&config.safety);
84        let step_mode_active = options.step;
85
86        Self {
87            session,
88            options,
89            detector,
90            output,
91            aborted: abort_flag,
92            step_mode_active,
93            executed_count: 0,
94            skipped_count: 0,
95            failed_count: 0,
96            skipped_dangerous: 0,
97            current_cwd: None,
98        }
99    }
100
101    /// Run the replay engine, processing all commands in the session.
102    ///
103    /// Returns a `ReplaySummary` with execution statistics.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if command execution or I/O fails.
108    pub fn run(&mut self) -> crate::error::Result<ReplaySummary> {
109        let total = self.session.commands.len();
110
111        // Install Ctrl+C signal handler (may fail if a global handler is already set)
112        let abort_flag = Arc::clone(&self.aborted);
113        let _ = ctrlc::set_handler(move || {
114            abort_flag.store(true, Ordering::SeqCst);
115        });
116
117        // Warn on incomplete session
118        if self.session.footer.is_none() {
119            self.output.warning(&format!(
120                "This session was not completed. Playing {total} available commands."
121            ));
122        }
123
124        // Non-interactive mode checks
125        if self.options.step && !prompt::is_interactive() {
126            return Err(crate::error::RecError::Config(
127                "Step mode requires an interactive terminal".to_string(),
128            ));
129        }
130        if self.options.danger_policy.is_none()
131            && !self.options.force
132            && !self.options.dry_run
133            && !prompt::is_interactive()
134        {
135            self.output
136                .warning("Non-interactive terminal: destructive command prompts will auto-deny");
137        }
138
139        // DangerPolicy::Abort — pre-scan ALL commands before executing any
140        if matches!(self.options.danger_policy, Some(DangerPolicy::Abort)) {
141            let dangerous: Vec<(usize, &str, String)> = self
142                .session
143                .commands
144                .iter()
145                .filter_map(|cmd| {
146                    self.detector
147                        .match_reason(&cmd.command)
148                        .map(|reason| (cmd.index as usize, cmd.command.as_str(), reason))
149                })
150                .collect();
151
152            if !dangerous.is_empty() {
153                eprintln!("error: Found {} dangerous command(s):", dangerous.len());
154                for (idx, cmd_text, reason) in &dangerous {
155                    eprintln!("  [{}] $ {} — {}", idx + 1, cmd_text, reason);
156                }
157                eprintln!("\nAborting without executing any commands.");
158                eprintln!("Use --danger-policy allow to execute all commands.");
159                return Ok(ReplaySummary {
160                    total,
161                    executed: 0,
162                    skipped: 0,
163                    failed: 0,
164                    aborted: true,
165                });
166            }
167        }
168
169        // DangerPolicy::Allow — print info notice before execution
170        if matches!(self.options.danger_policy, Some(DangerPolicy::Allow)) {
171            let dangerous_count = self
172                .session
173                .commands
174                .iter()
175                .filter(|cmd| self.detector.is_destructive(&cmd.command))
176                .count();
177            if dangerous_count > 0 {
178                eprintln!(
179                    "note: Executing {dangerous_count} dangerous command(s) (--danger-policy allow)"
180                );
181            }
182        }
183
184        // Process each command
185        for cmd in &self.session.commands.clone() {
186            // Check abort flag
187            if self.aborted.load(Ordering::SeqCst) {
188                break;
189            }
190
191            let index = cmd.index as usize;
192
193            // --from filter: skip commands before start index (silently)
194            if let Some(from_idx) = self.options.from_index {
195                if index < from_idx {
196                    continue;
197                }
198            }
199
200            // --skip indices filter
201            if self.options.skip_indices.contains(&index) {
202                self.skipped_count += 1;
203                self.output.info(&format!(
204                    "[{}/{}] [skipped] $ {}",
205                    index + 1,
206                    total,
207                    cmd.command
208                ));
209                continue;
210            }
211
212            // --skip-pattern filter
213            if self
214                .options
215                .skip_patterns
216                .iter()
217                .any(|p| p.matches(&cmd.command))
218            {
219                self.skipped_count += 1;
220                self.output.info(&format!(
221                    "[{}/{}] [skipped] $ {}",
222                    index + 1,
223                    total,
224                    cmd.command
225                ));
226                continue;
227            }
228
229            // Check destructive
230            let is_destructive = self.detector.is_destructive(&cmd.command);
231
232            // Dry-run mode: display command info without executing
233            if self.options.dry_run {
234                self.print_dry_run(cmd, index, total, is_destructive);
235                continue;
236            }
237
238            // Step mode: prompt for action
239            if self.step_mode_active {
240                let action = prompt::prompt_step(&cmd.command, index, total, is_destructive);
241                match action {
242                    prompt::StepAction::Run => { /* proceed to execution */ }
243                    prompt::StepAction::Skip => {
244                        self.skipped_count += 1;
245                        continue;
246                    }
247                    prompt::StepAction::Abort => {
248                        self.aborted.store(true, Ordering::SeqCst);
249                        break;
250                    }
251                    prompt::StepAction::RunAll => {
252                        self.step_mode_active = false;
253                        // proceed to execution
254                    }
255                }
256            } else if is_destructive {
257                // Destructive command handling based on danger_policy
258                match self.options.danger_policy {
259                    Some(DangerPolicy::Skip) => {
260                        self.skipped_count += 1;
261                        self.skipped_dangerous += 1;
262                        eprintln!(
263                            "warning: Skipped dangerous command [{}/{}]: {} (use --danger-policy allow to override)",
264                            index + 1,
265                            total,
266                            cmd.command
267                        );
268                        continue;
269                    }
270                    Some(DangerPolicy::Allow) => {
271                        // Proceed to execution (info notice printed once at start)
272                    }
273                    Some(DangerPolicy::Abort) => {
274                        // Should not reach here — abort is handled in pre-scan above
275                        unreachable!(
276                            "DangerPolicy::Abort should be handled by pre-scan before the command loop"
277                        );
278                    }
279                    None if self.options.force => {
280                        // Legacy --force behavior: bypass prompts
281                    }
282                    None => {
283                        if prompt::is_interactive() {
284                            // Interactive: prompt for confirmation
285                            let reason = self
286                                .detector
287                                .match_reason(&cmd.command)
288                                .unwrap_or_else(|| "Matched destructive pattern".to_string());
289                            if !prompt::prompt_destructive(&cmd.command, &reason) {
290                                self.skipped_count += 1;
291                                continue;
292                            }
293                        } else {
294                            // Non-interactive without explicit policy: default to skip
295                            self.skipped_count += 1;
296                            self.output.warning(&format!(
297                                "[{}/{}] Skipping destructive command (non-interactive): $ {}",
298                                index + 1,
299                                total,
300                                cmd.command
301                            ));
302                            continue;
303                        }
304                    }
305                }
306            }
307
308            // Execute command with retry loop
309            loop {
310                // Print command being executed
311                if self.output.colors {
312                    eprintln!("\x1b[1m$ {}\x1b[0m", cmd.command);
313                } else {
314                    eprintln!("$ {}", cmd.command);
315                }
316
317                // Determine cwd: --cwd uses original, otherwise use tracked current_cwd
318                let cwd: Option<&Path> = if self.options.use_original_cwd {
319                    Some(cmd.cwd.as_path())
320                } else {
321                    self.current_cwd.as_deref()
322                };
323
324                // Execute
325                let result = executor::execute_command(&cmd.command, cwd);
326                self.executed_count += 1;
327
328                match result {
329                    Ok(exec_result) => {
330                        // Update current_cwd if this was a successful cd command
331                        if let Some(new_cwd) = exec_result.new_cwd {
332                            if !self.options.use_original_cwd {
333                                self.current_cwd = Some(new_cwd);
334                            }
335                        }
336
337                        if exec_result.status.success() {
338                            break; // Command succeeded, move to next
339                        }
340
341                        // Command failed
342                        self.failed_count += 1;
343                        let exit_code = exec_result.status.code();
344
345                        if prompt::is_interactive() && !self.aborted.load(Ordering::SeqCst) {
346                            let action = prompt::prompt_error(&cmd.command, exit_code);
347                            match action {
348                                prompt::ErrorAction::Continue => break,
349                                prompt::ErrorAction::Abort => {
350                                    self.aborted.store(true, Ordering::SeqCst);
351                                    break;
352                                }
353                                prompt::ErrorAction::Retry => {
354                                    // Undo the executed_count increment for retry
355                                    self.executed_count -= 1;
356                                    self.failed_count -= 1;
357                                    continue; // Retry the same command
358                                }
359                            }
360                        }
361                        // Non-interactive: print error and continue
362                        eprintln!(
363                            "  Command failed with exit code: {}",
364                            exit_code.map_or_else(|| "unknown".to_string(), |c| c.to_string())
365                        );
366                        break;
367                    }
368                    Err(e) => {
369                        self.failed_count += 1;
370                        eprintln!("  Failed to execute command: {e}");
371                        break;
372                    }
373                }
374            }
375
376            // Check abort after execution
377            if self.aborted.load(Ordering::SeqCst) {
378                break;
379            }
380        }
381
382        // DangerPolicy::Skip end summary
383        if matches!(self.options.danger_policy, Some(DangerPolicy::Skip))
384            && self.skipped_dangerous > 0
385        {
386            eprintln!(
387                "warning: Skipped {} of {} commands (dangerous). Re-run with --danger-policy allow to include them.",
388                self.skipped_dangerous, total
389            );
390        }
391
392        // Print summary
393        let aborted = self.aborted.load(Ordering::SeqCst);
394        self.print_summary(total, aborted);
395
396        Ok(ReplaySummary {
397            total,
398            executed: self.executed_count,
399            skipped: self.skipped_count,
400            failed: self.failed_count,
401            aborted,
402        })
403    }
404
405    /// Print dry-run output for a single command.
406    fn print_dry_run(
407        &self,
408        cmd: &crate::models::Command,
409        index: usize,
410        total: usize,
411        is_destructive: bool,
412    ) {
413        let dangerous_marker = if is_destructive { " [DANGEROUS]" } else { "" };
414        let warn_suffix = if is_destructive {
415            format!(" {}", self.output.warning_symbol())
416        } else {
417            String::new()
418        };
419
420        println!(
421            "[{}/{}] $ {}{}{}",
422            index + 1,
423            total,
424            cmd.command,
425            dangerous_marker,
426            warn_suffix
427        );
428        println!("        cwd: {}", cmd.cwd.display());
429        if let Some(exit_code) = cmd.exit_code {
430            println!("        exit: {exit_code}");
431        }
432        if let Some(duration) = cmd.duration_ms {
433            println!("        duration: {duration}ms");
434        }
435        if is_destructive {
436            if self.output.colors {
437                println!("        \x1b[31;1m\u{26a0} DESTRUCTIVE\x1b[0m");
438            } else {
439                println!("        [WARN] DESTRUCTIVE");
440            }
441        }
442        println!();
443    }
444
445    /// Print replay completion summary.
446    fn print_summary(&self, total: usize, aborted: bool) {
447        println!();
448        if aborted {
449            self.output.warning("Replay aborted by user");
450        }
451        self.output.success(&format!(
452            "Replay complete: {}/{} commands executed, {} skipped, {} failed",
453            self.executed_count, total, self.skipped_count, self.failed_count
454        ));
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::models::{Command, Session};
462    use std::path::PathBuf;
463
464    fn create_test_session(commands: &[&str]) -> Session {
465        let mut session = Session::new("test-replay");
466        for (i, cmd_text) in commands.iter().enumerate() {
467            let mut cmd = Command::new(i as u32, cmd_text.to_string(), PathBuf::from("/tmp"));
468            cmd.complete(0);
469            session.add_command(cmd);
470        }
471        session
472    }
473
474    #[test]
475    fn test_engine_creation() {
476        let session = create_test_session(&["echo hello"]);
477        let config = Config::default();
478        let output = Output::new(false, false, false);
479        let options = ReplayOptions::default();
480
481        let engine = ReplayEngine::new(session, options, &config, output);
482        assert_eq!(engine.executed_count, 0);
483        assert_eq!(engine.skipped_count, 0);
484        assert_eq!(engine.failed_count, 0);
485        assert!(!engine.step_mode_active);
486    }
487
488    #[test]
489    fn test_dry_run_does_not_execute() {
490        let session = create_test_session(&["echo hello", "echo world"]);
491        let config = Config::default();
492        let output = Output::new(false, true, false); // quiet to reduce noise
493        let options = ReplayOptions {
494            dry_run: true,
495            ..Default::default()
496        };
497
498        let mut engine = ReplayEngine::new(session, options, &config, output);
499        let summary = engine.run().unwrap();
500
501        assert_eq!(summary.total, 2);
502        assert_eq!(summary.executed, 0);
503        assert_eq!(summary.skipped, 0);
504        assert!(!summary.aborted);
505    }
506
507    #[test]
508    fn test_skip_indices() {
509        let session = create_test_session(&["echo a", "echo b", "echo c"]);
510        let config = Config::default();
511        let output = Output::new(false, true, false);
512        let mut options = ReplayOptions {
513            dry_run: true,
514            ..Default::default()
515        };
516        options.skip_indices.insert(1); // skip second command (0-based)
517
518        let mut engine = ReplayEngine::new(session, options, &config, output);
519        let summary = engine.run().unwrap();
520
521        assert_eq!(summary.total, 3);
522        assert_eq!(summary.skipped, 1);
523    }
524
525    #[test]
526    fn test_from_index() {
527        let session = create_test_session(&["echo a", "echo b", "echo c"]);
528        let config = Config::default();
529        let output = Output::new(false, true, false);
530        let options = ReplayOptions {
531            dry_run: true,
532            from_index: Some(2), // start from third command (0-based index 2)
533            ..Default::default()
534        };
535
536        let mut engine = ReplayEngine::new(session, options, &config, output);
537        let summary = engine.run().unwrap();
538
539        // Only the third command should be processed (not skipped, just displayed in dry-run)
540        assert_eq!(summary.total, 3);
541        assert_eq!(summary.executed, 0); // dry-run
542        assert_eq!(summary.skipped, 0); // from_index doesn't count as skipped
543    }
544
545    #[test]
546    fn test_skip_pattern() {
547        let session = create_test_session(&["echo hello", "rm -rf /tmp/test", "ls -la"]);
548        let config = Config::default();
549        let output = Output::new(false, true, false);
550        let mut options = ReplayOptions {
551            dry_run: true,
552            ..Default::default()
553        };
554        options
555            .skip_patterns
556            .push(glob::Pattern::new("rm*").unwrap());
557
558        let mut engine = ReplayEngine::new(session, options, &config, output);
559        let summary = engine.run().unwrap();
560
561        assert_eq!(summary.total, 3);
562        assert_eq!(summary.skipped, 1);
563    }
564
565    #[test]
566    fn test_execute_safe_commands() {
567        let session = create_test_session(&["echo hello", "echo world"]);
568        let config = Config::default();
569        let output = Output::new(false, true, false);
570        let options = ReplayOptions::default();
571
572        let mut engine = ReplayEngine::new(session, options, &config, output);
573        let summary = engine.run().unwrap();
574
575        assert_eq!(summary.total, 2);
576        assert_eq!(summary.executed, 2);
577        assert_eq!(summary.failed, 0);
578        assert!(!summary.aborted);
579    }
580
581    #[test]
582    fn test_incomplete_session_warns() {
583        // Session without footer (incomplete)
584        let mut session = Session::new("incomplete-test");
585        let cmd = Command::new(0, "echo test".to_string(), PathBuf::from("/tmp"));
586        session.add_command(cmd);
587        // Don't call session.complete() — leave footer as None
588
589        let config = Config::default();
590        let output = Output::new(false, false, false);
591        let options = ReplayOptions {
592            dry_run: true,
593            ..Default::default()
594        };
595
596        let mut engine = ReplayEngine::new(session, options, &config, output);
597        let summary = engine.run().unwrap();
598
599        // Should still work with available commands
600        assert_eq!(summary.total, 1);
601    }
602}