1use 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
19pub 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_cwd: Option<std::path::PathBuf>,
34}
35
36impl ReplayEngine {
37 #[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 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 pub fn run(&mut self) -> crate::error::Result<ReplaySummary> {
109 let total = self.session.commands.len();
110
111 let abort_flag = Arc::clone(&self.aborted);
113 let _ = ctrlc::set_handler(move || {
114 abort_flag.store(true, Ordering::SeqCst);
115 });
116
117 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 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 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 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 for cmd in &self.session.commands.clone() {
186 if self.aborted.load(Ordering::SeqCst) {
188 break;
189 }
190
191 let index = cmd.index as usize;
192
193 if let Some(from_idx) = self.options.from_index {
195 if index < from_idx {
196 continue;
197 }
198 }
199
200 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 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 let is_destructive = self.detector.is_destructive(&cmd.command);
231
232 if self.options.dry_run {
234 self.print_dry_run(cmd, index, total, is_destructive);
235 continue;
236 }
237
238 if self.step_mode_active {
240 let action = prompt::prompt_step(&cmd.command, index, total, is_destructive);
241 match action {
242 prompt::StepAction::Run => { }
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 }
255 }
256 } else if is_destructive {
257 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 }
273 Some(DangerPolicy::Abort) => {
274 unreachable!(
276 "DangerPolicy::Abort should be handled by pre-scan before the command loop"
277 );
278 }
279 None if self.options.force => {
280 }
282 None => {
283 if prompt::is_interactive() {
284 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 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 loop {
310 if self.output.colors {
312 eprintln!("\x1b[1m$ {}\x1b[0m", cmd.command);
313 } else {
314 eprintln!("$ {}", cmd.command);
315 }
316
317 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 let result = executor::execute_command(&cmd.command, cwd);
326 self.executed_count += 1;
327
328 match result {
329 Ok(exec_result) => {
330 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; }
340
341 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 self.executed_count -= 1;
356 self.failed_count -= 1;
357 continue; }
359 }
360 }
361 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 if self.aborted.load(Ordering::SeqCst) {
378 break;
379 }
380 }
381
382 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 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 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 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); 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); 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), ..Default::default()
534 };
535
536 let mut engine = ReplayEngine::new(session, options, &config, output);
537 let summary = engine.run().unwrap();
538
539 assert_eq!(summary.total, 3);
541 assert_eq!(summary.executed, 0); assert_eq!(summary.skipped, 0); }
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 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 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 assert_eq!(summary.total, 1);
601 }
602}