1use crate::command::{CommandExecutor, CommandOutput};
4use crate::error::Result;
5use crate::tracing_compat::{debug, info, trace, warn};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8use tokio::time::sleep;
9
10#[derive(Debug, Clone)]
12pub struct DebugConfig {
13 pub dry_run: bool,
15
16 pub verbose: bool,
18
19 pub command_log: Arc<Mutex<Vec<String>>>,
21
22 pub dry_run_prefix: String,
24}
25
26impl Default for DebugConfig {
27 fn default() -> Self {
28 Self {
29 dry_run: false,
30 verbose: false,
31 command_log: Arc::new(Mutex::new(Vec::new())),
32 dry_run_prefix: "[DRY RUN]".to_string(),
33 }
34 }
35}
36
37impl DebugConfig {
38 #[must_use]
40 pub fn new() -> Self {
41 Self::default()
42 }
43
44 #[must_use]
46 pub fn dry_run(mut self, enabled: bool) -> Self {
47 self.dry_run = enabled;
48 self
49 }
50
51 #[must_use]
53 pub fn verbose(mut self, enabled: bool) -> Self {
54 self.verbose = enabled;
55 self
56 }
57
58 #[must_use]
60 pub fn dry_run_prefix(mut self, prefix: impl Into<String>) -> Self {
61 self.dry_run_prefix = prefix.into();
62 self
63 }
64
65 pub fn log_command(&self, command: &str) {
67 if let Ok(mut log) = self.command_log.lock() {
68 log.push(command.to_string());
69 }
70 }
71
72 #[must_use]
74 pub fn get_command_log(&self) -> Vec<String> {
75 self.command_log
76 .lock()
77 .map(|log| log.clone())
78 .unwrap_or_default()
79 }
80
81 pub fn clear_log(&self) {
83 if let Ok(mut log) = self.command_log.lock() {
84 log.clear();
85 }
86 }
87}
88
89pub type RetryCallback = Arc<dyn Fn(u32, &str) + Send + Sync>;
91
92#[derive(Clone)]
94pub struct RetryPolicy {
95 pub max_attempts: u32,
97
98 pub backoff: BackoffStrategy,
100
101 pub on_retry: Option<RetryCallback>,
103}
104
105impl std::fmt::Debug for RetryPolicy {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 f.debug_struct("RetryPolicy")
108 .field("max_attempts", &self.max_attempts)
109 .field("backoff", &self.backoff)
110 .field("on_retry", &self.on_retry.is_some())
111 .finish()
112 }
113}
114
115#[derive(Debug, Clone)]
117pub enum BackoffStrategy {
118 Fixed(Duration),
120
121 Linear {
123 initial: Duration,
125 increment: Duration,
127 },
128
129 Exponential {
131 initial: Duration,
133 max: Duration,
135 multiplier: f64,
137 },
138}
139
140impl Default for RetryPolicy {
141 fn default() -> Self {
142 Self {
143 max_attempts: 3,
144 backoff: BackoffStrategy::Exponential {
145 initial: Duration::from_millis(100),
146 max: Duration::from_secs(10),
147 multiplier: 2.0,
148 },
149 on_retry: None,
150 }
151 }
152}
153
154impl RetryPolicy {
155 #[must_use]
157 pub fn new() -> Self {
158 Self::default()
159 }
160
161 #[must_use]
163 pub fn max_attempts(mut self, attempts: u32) -> Self {
164 self.max_attempts = attempts;
165 self
166 }
167
168 #[must_use]
170 pub fn backoff(mut self, strategy: BackoffStrategy) -> Self {
171 self.backoff = strategy;
172 self
173 }
174
175 #[must_use]
177 pub fn on_retry<F>(mut self, callback: F) -> Self
178 where
179 F: Fn(u32, &str) + Send + Sync + 'static,
180 {
181 self.on_retry = Some(Arc::new(callback));
182 self
183 }
184
185 #[must_use]
187 pub fn calculate_delay(&self, attempt: u32) -> Duration {
188 match &self.backoff {
189 BackoffStrategy::Fixed(delay) => *delay,
190
191 BackoffStrategy::Linear { initial, increment } => {
192 *initial + (*increment * (attempt - 1))
193 }
194
195 BackoffStrategy::Exponential {
196 initial,
197 max,
198 multiplier,
199 } => {
200 #[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
201 let delay_ms =
202 initial.as_millis() as f64 * multiplier.powi(attempt.saturating_sub(1) as i32);
203 #[allow(clippy::cast_precision_loss)]
204 let capped_ms = delay_ms.min(max.as_millis() as f64);
205 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
206 Duration::from_millis(capped_ms as u64)
207 }
208 }
209 }
210
211 #[must_use]
213 pub fn is_retryable(error_str: &str) -> bool {
214 error_str.contains("connection refused")
216 || error_str.contains("timeout")
217 || error_str.contains("temporarily unavailable")
218 || error_str.contains("resource temporarily unavailable")
219 || error_str.contains("Cannot connect to the Docker daemon")
220 || error_str.contains("503 Service Unavailable")
221 || error_str.contains("502 Bad Gateway")
222 }
223}
224
225#[derive(Debug, Clone)]
227pub struct DebugExecutor {
228 pub executor: CommandExecutor,
230
231 pub debug_config: DebugConfig,
233
234 pub retry_policy: Option<RetryPolicy>,
236}
237
238impl DebugExecutor {
239 #[must_use]
241 pub fn new() -> Self {
242 Self {
243 executor: CommandExecutor::new(),
244 debug_config: DebugConfig::default(),
245 retry_policy: None,
246 }
247 }
248
249 #[must_use]
251 pub fn dry_run(mut self, enabled: bool) -> Self {
252 self.debug_config = self.debug_config.dry_run(enabled);
253 self
254 }
255
256 #[must_use]
258 pub fn verbose(mut self, enabled: bool) -> Self {
259 self.debug_config = self.debug_config.verbose(enabled);
260 self
261 }
262
263 #[must_use]
265 pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
266 self.retry_policy = Some(policy);
267 self
268 }
269
270 #[cfg_attr(
276 feature = "tracing",
277 tracing::instrument(
278 name = "debug.execute",
279 skip(self, args),
280 fields(
281 command = %command_name,
282 dry_run = self.debug_config.dry_run,
283 has_retry = self.retry_policy.is_some(),
284 )
285 )
286 )]
287 pub async fn execute_command(
288 &self,
289 command_name: &str,
290 args: Vec<String>,
291 ) -> Result<CommandOutput> {
292 let command_str = format!("docker {} {}", command_name, args.join(" "));
293
294 self.debug_config.log_command(&command_str);
296
297 trace!(command = %command_str, "executing debug command");
298
299 if self.debug_config.verbose {
301 eprintln!("[VERBOSE] Executing: {command_str}");
302 }
303
304 if self.debug_config.dry_run {
306 let message = format!(
307 "{} Would execute: {}",
308 self.debug_config.dry_run_prefix, command_str
309 );
310 eprintln!("{message}");
311 info!(command = %command_str, "dry-run mode, command not executed");
312
313 return Ok(CommandOutput {
314 stdout: message,
315 stderr: String::new(),
316 exit_code: 0,
317 success: true,
318 });
319 }
320
321 if let Some(ref policy) = self.retry_policy {
323 self.execute_with_retry(command_name, args, policy).await
324 } else {
325 self.executor.execute_command(command_name, args).await
326 }
327 }
328
329 #[cfg_attr(
331 feature = "tracing",
332 tracing::instrument(
333 name = "debug.retry",
334 skip(self, args, policy),
335 fields(
336 command = %command_name,
337 max_attempts = policy.max_attempts,
338 )
339 )
340 )]
341 async fn execute_with_retry(
342 &self,
343 command_name: &str,
344 args: Vec<String>,
345 policy: &RetryPolicy,
346 ) -> Result<CommandOutput> {
347 let mut attempt = 0;
348 let mut last_error = None;
349
350 debug!(
351 max_attempts = policy.max_attempts,
352 "starting command execution with retry"
353 );
354
355 while attempt < policy.max_attempts {
356 attempt += 1;
357
358 trace!(attempt = attempt, "executing attempt");
359
360 if self.debug_config.verbose && attempt > 1 {
361 eprintln!(
362 "[VERBOSE] Retry attempt {}/{}",
363 attempt, policy.max_attempts
364 );
365 }
366
367 match self
368 .executor
369 .execute_command(command_name, args.clone())
370 .await
371 {
372 Ok(output) => {
373 if attempt > 1 {
374 info!(attempt = attempt, "command succeeded after retry");
375 }
376 return Ok(output);
377 }
378 Err(e) => {
379 let error_str = e.to_string();
380
381 if !RetryPolicy::is_retryable(&error_str) {
383 debug!(
384 error = %error_str,
385 "error is not retryable, failing immediately"
386 );
387 return Err(e);
388 }
389
390 if attempt >= policy.max_attempts {
392 warn!(
393 attempt = attempt,
394 max_attempts = policy.max_attempts,
395 error = %error_str,
396 "all retry attempts exhausted"
397 );
398 return Err(e);
399 }
400
401 if let Some(ref callback) = policy.on_retry {
403 callback(attempt, &error_str);
404 }
405
406 let delay = policy.calculate_delay(attempt);
408
409 #[allow(clippy::cast_possible_truncation)]
410 #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
411 let delay_ms = delay.as_millis() as u64;
412 warn!(
413 attempt = attempt,
414 max_attempts = policy.max_attempts,
415 error = %error_str,
416 delay_ms = delay_ms,
417 "command failed, will retry after delay"
418 );
419
420 if self.debug_config.verbose {
421 eprintln!("[VERBOSE] Waiting {delay:?} before retry");
422 }
423 sleep(delay).await;
424
425 last_error = Some(e);
426 }
427 }
428 }
429
430 Err(last_error.unwrap_or_else(|| crate::error::Error::custom("Retry failed")))
431 }
432
433 #[must_use]
435 pub fn get_command_log(&self) -> Vec<String> {
436 self.debug_config.get_command_log()
437 }
438
439 pub fn clear_log(&self) {
441 self.debug_config.clear_log();
442 }
443}
444
445impl Default for DebugExecutor {
446 fn default() -> Self {
447 Self::new()
448 }
449}
450
451pub struct DryRunPreview {
453 pub commands: Vec<String>,
455}
456
457impl DryRunPreview {
458 #[must_use]
460 pub fn new(commands: Vec<String>) -> Self {
461 Self { commands }
462 }
463
464 pub fn print(&self) {
466 if self.commands.is_empty() {
467 println!("No commands would be executed.");
468 return;
469 }
470
471 println!("Would execute the following commands:");
472 for (i, cmd) in self.commands.iter().enumerate() {
473 println!(" {}. {}", i + 1, cmd);
474 }
475 }
476}
477
478impl std::fmt::Display for DryRunPreview {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 if self.commands.is_empty() {
481 return write!(f, "No commands would be executed.");
482 }
483
484 writeln!(f, "Would execute the following commands:")?;
485 for (i, cmd) in self.commands.iter().enumerate() {
486 writeln!(f, " {}. {}", i + 1, cmd)?;
487 }
488 Ok(())
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn test_debug_config() {
498 let config = DebugConfig::new()
499 .dry_run(true)
500 .verbose(true)
501 .dry_run_prefix("[TEST]");
502
503 assert!(config.dry_run);
504 assert!(config.verbose);
505 assert_eq!(config.dry_run_prefix, "[TEST]");
506 }
507
508 #[test]
509 fn test_retry_policy_delay() {
510 let policy = RetryPolicy::new().backoff(BackoffStrategy::Fixed(Duration::from_millis(100)));
512 assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
513 assert_eq!(policy.calculate_delay(3), Duration::from_millis(100));
514
515 let policy = RetryPolicy::new().backoff(BackoffStrategy::Linear {
517 initial: Duration::from_millis(100),
518 increment: Duration::from_millis(50),
519 });
520 assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
521 assert_eq!(policy.calculate_delay(2), Duration::from_millis(150));
522 assert_eq!(policy.calculate_delay(3), Duration::from_millis(200));
523
524 let policy = RetryPolicy::new().backoff(BackoffStrategy::Exponential {
526 initial: Duration::from_millis(100),
527 max: Duration::from_secs(1),
528 multiplier: 2.0,
529 });
530 assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
531 assert_eq!(policy.calculate_delay(2), Duration::from_millis(200));
532 assert_eq!(policy.calculate_delay(3), Duration::from_millis(400));
533 assert_eq!(policy.calculate_delay(5), Duration::from_secs(1)); }
535
536 #[test]
537 fn test_retryable_errors() {
538 assert!(RetryPolicy::is_retryable("connection refused"));
539 assert!(RetryPolicy::is_retryable("operation timeout"));
540 assert!(RetryPolicy::is_retryable(
541 "Cannot connect to the Docker daemon"
542 ));
543 assert!(!RetryPolicy::is_retryable("image not found"));
544 assert!(!RetryPolicy::is_retryable("permission denied"));
545 }
546
547 #[test]
548 fn test_command_logging() {
549 let config = DebugConfig::new();
550 config.log_command("docker ps -a");
551 config.log_command("docker run nginx");
552
553 let log = config.get_command_log();
554 assert_eq!(log.len(), 2);
555 assert_eq!(log[0], "docker ps -a");
556 assert_eq!(log[1], "docker run nginx");
557
558 config.clear_log();
559 assert_eq!(config.get_command_log().len(), 0);
560 }
561
562 #[test]
563 fn test_dry_run_preview() {
564 let commands = vec![
565 "docker pull nginx".to_string(),
566 "docker run -d nginx".to_string(),
567 ];
568
569 let preview = DryRunPreview::new(commands);
570 let output = preview.to_string();
571
572 assert!(output.contains("Would execute"));
573 assert!(output.contains("1. docker pull nginx"));
574 assert!(output.contains("2. docker run -d nginx"));
575 }
576}