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}
33
34impl ReplayEngine {
35 #[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 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 pub fn run(&mut self) -> crate::error::Result<ReplaySummary> {
105 let total = self.session.commands.len();
106
107 let abort_flag = Arc::clone(&self.aborted);
109 let _ = ctrlc::set_handler(move || {
110 abort_flag.store(true, Ordering::SeqCst);
111 });
112
113 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 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 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 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 for cmd in &self.session.commands.clone() {
182 if self.aborted.load(Ordering::SeqCst) {
184 break;
185 }
186
187 let index = cmd.index as usize;
188
189 if let Some(from_idx) = self.options.from_index {
191 if index < from_idx {
192 continue;
193 }
194 }
195
196 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 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 let is_destructive = self.detector.is_destructive(&cmd.command);
227
228 if self.options.dry_run {
230 self.print_dry_run(cmd, index, total, is_destructive);
231 continue;
232 }
233
234 if self.step_mode_active {
236 let action = prompt::prompt_step(&cmd.command, index, total, is_destructive);
237 match action {
238 prompt::StepAction::Run => { }
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 }
251 }
252 } else if is_destructive {
253 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 }
269 Some(DangerPolicy::Abort) => {
270 unreachable!(
272 "DangerPolicy::Abort should be handled by pre-scan before the command loop"
273 );
274 }
275 None if self.options.force => {
276 }
278 None => {
279 if prompt::is_interactive() {
280 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 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 loop {
306 if self.output.colors {
308 eprintln!("\x1b[1m$ {}\x1b[0m", cmd.command);
309 } else {
310 eprintln!("$ {}", cmd.command);
311 }
312
313 let cwd: Option<&Path> = if self.options.use_original_cwd {
315 Some(cmd.cwd.as_path())
316 } else {
317 None
318 };
319
320 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; }
329
330 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 self.executed_count -= 1;
345 self.failed_count -= 1;
346 continue; }
348 }
349 }
350 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 if self.aborted.load(Ordering::SeqCst) {
367 break;
368 }
369 }
370
371 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 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 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 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); 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); 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), ..Default::default()
523 };
524
525 let mut engine = ReplayEngine::new(session, options, &config, output);
526 let summary = engine.run().unwrap();
527
528 assert_eq!(summary.total, 3);
530 assert_eq!(summary.executed, 0); assert_eq!(summary.skipped, 0); }
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 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 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 assert_eq!(summary.total, 1);
590 }
591}