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