1use crate::command::{CommandExecutor, CommandOutput};
4use crate::error::Result;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7use tokio::time::sleep;
8use tracing::{debug, info, instrument, trace, warn};
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 #[instrument(
276 name = "debug.execute",
277 skip(self, args),
278 fields(
279 command = %command_name,
280 dry_run = self.debug_config.dry_run,
281 has_retry = self.retry_policy.is_some(),
282 )
283 )]
284 pub async fn execute_command(
285 &self,
286 command_name: &str,
287 args: Vec<String>,
288 ) -> Result<CommandOutput> {
289 let command_str = format!("docker {} {}", command_name, args.join(" "));
290
291 self.debug_config.log_command(&command_str);
293
294 trace!(command = %command_str, "executing debug command");
295
296 if self.debug_config.verbose {
298 eprintln!("[VERBOSE] Executing: {command_str}");
299 }
300
301 if self.debug_config.dry_run {
303 let message = format!(
304 "{} Would execute: {}",
305 self.debug_config.dry_run_prefix, command_str
306 );
307 eprintln!("{message}");
308 info!(command = %command_str, "dry-run mode, command not executed");
309
310 return Ok(CommandOutput {
311 stdout: message,
312 stderr: String::new(),
313 exit_code: 0,
314 success: true,
315 });
316 }
317
318 if let Some(ref policy) = self.retry_policy {
320 self.execute_with_retry(command_name, args, policy).await
321 } else {
322 self.executor.execute_command(command_name, args).await
323 }
324 }
325
326 #[instrument(
328 name = "debug.retry",
329 skip(self, args, policy),
330 fields(
331 command = %command_name,
332 max_attempts = policy.max_attempts,
333 )
334 )]
335 async fn execute_with_retry(
336 &self,
337 command_name: &str,
338 args: Vec<String>,
339 policy: &RetryPolicy,
340 ) -> Result<CommandOutput> {
341 let mut attempt = 0;
342 let mut last_error = None;
343
344 debug!(
345 max_attempts = policy.max_attempts,
346 "starting command execution with retry"
347 );
348
349 while attempt < policy.max_attempts {
350 attempt += 1;
351
352 trace!(attempt = attempt, "executing attempt");
353
354 if self.debug_config.verbose && attempt > 1 {
355 eprintln!(
356 "[VERBOSE] Retry attempt {}/{}",
357 attempt, policy.max_attempts
358 );
359 }
360
361 match self
362 .executor
363 .execute_command(command_name, args.clone())
364 .await
365 {
366 Ok(output) => {
367 if attempt > 1 {
368 info!(attempt = attempt, "command succeeded after retry");
369 }
370 return Ok(output);
371 }
372 Err(e) => {
373 let error_str = e.to_string();
374
375 if !RetryPolicy::is_retryable(&error_str) {
377 debug!(
378 error = %error_str,
379 "error is not retryable, failing immediately"
380 );
381 return Err(e);
382 }
383
384 if attempt >= policy.max_attempts {
386 warn!(
387 attempt = attempt,
388 max_attempts = policy.max_attempts,
389 error = %error_str,
390 "all retry attempts exhausted"
391 );
392 return Err(e);
393 }
394
395 if let Some(ref callback) = policy.on_retry {
397 callback(attempt, &error_str);
398 }
399
400 let delay = policy.calculate_delay(attempt);
402
403 #[allow(clippy::cast_possible_truncation)]
404 let delay_ms = delay.as_millis() as u64;
405 warn!(
406 attempt = attempt,
407 max_attempts = policy.max_attempts,
408 error = %error_str,
409 delay_ms = delay_ms,
410 "command failed, will retry after delay"
411 );
412
413 if self.debug_config.verbose {
414 eprintln!("[VERBOSE] Waiting {delay:?} before retry");
415 }
416 sleep(delay).await;
417
418 last_error = Some(e);
419 }
420 }
421 }
422
423 Err(last_error.unwrap_or_else(|| crate::error::Error::custom("Retry failed")))
424 }
425
426 #[must_use]
428 pub fn get_command_log(&self) -> Vec<String> {
429 self.debug_config.get_command_log()
430 }
431
432 pub fn clear_log(&self) {
434 self.debug_config.clear_log();
435 }
436}
437
438impl Default for DebugExecutor {
439 fn default() -> Self {
440 Self::new()
441 }
442}
443
444pub struct DryRunPreview {
446 pub commands: Vec<String>,
448}
449
450impl DryRunPreview {
451 #[must_use]
453 pub fn new(commands: Vec<String>) -> Self {
454 Self { commands }
455 }
456
457 pub fn print(&self) {
459 if self.commands.is_empty() {
460 println!("No commands would be executed.");
461 return;
462 }
463
464 println!("Would execute the following commands:");
465 for (i, cmd) in self.commands.iter().enumerate() {
466 println!(" {}. {}", i + 1, cmd);
467 }
468 }
469}
470
471impl std::fmt::Display for DryRunPreview {
472 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
473 if self.commands.is_empty() {
474 return write!(f, "No commands would be executed.");
475 }
476
477 writeln!(f, "Would execute the following commands:")?;
478 for (i, cmd) in self.commands.iter().enumerate() {
479 writeln!(f, " {}. {}", i + 1, cmd)?;
480 }
481 Ok(())
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn test_debug_config() {
491 let config = DebugConfig::new()
492 .dry_run(true)
493 .verbose(true)
494 .dry_run_prefix("[TEST]");
495
496 assert!(config.dry_run);
497 assert!(config.verbose);
498 assert_eq!(config.dry_run_prefix, "[TEST]");
499 }
500
501 #[test]
502 fn test_retry_policy_delay() {
503 let policy = RetryPolicy::new().backoff(BackoffStrategy::Fixed(Duration::from_millis(100)));
505 assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
506 assert_eq!(policy.calculate_delay(3), Duration::from_millis(100));
507
508 let policy = RetryPolicy::new().backoff(BackoffStrategy::Linear {
510 initial: Duration::from_millis(100),
511 increment: Duration::from_millis(50),
512 });
513 assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
514 assert_eq!(policy.calculate_delay(2), Duration::from_millis(150));
515 assert_eq!(policy.calculate_delay(3), Duration::from_millis(200));
516
517 let policy = RetryPolicy::new().backoff(BackoffStrategy::Exponential {
519 initial: Duration::from_millis(100),
520 max: Duration::from_secs(1),
521 multiplier: 2.0,
522 });
523 assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
524 assert_eq!(policy.calculate_delay(2), Duration::from_millis(200));
525 assert_eq!(policy.calculate_delay(3), Duration::from_millis(400));
526 assert_eq!(policy.calculate_delay(5), Duration::from_secs(1)); }
528
529 #[test]
530 fn test_retryable_errors() {
531 assert!(RetryPolicy::is_retryable("connection refused"));
532 assert!(RetryPolicy::is_retryable("operation timeout"));
533 assert!(RetryPolicy::is_retryable(
534 "Cannot connect to the Docker daemon"
535 ));
536 assert!(!RetryPolicy::is_retryable("image not found"));
537 assert!(!RetryPolicy::is_retryable("permission denied"));
538 }
539
540 #[test]
541 fn test_command_logging() {
542 let config = DebugConfig::new();
543 config.log_command("docker ps -a");
544 config.log_command("docker run nginx");
545
546 let log = config.get_command_log();
547 assert_eq!(log.len(), 2);
548 assert_eq!(log[0], "docker ps -a");
549 assert_eq!(log[1], "docker run nginx");
550
551 config.clear_log();
552 assert_eq!(config.get_command_log().len(), 0);
553 }
554
555 #[test]
556 fn test_dry_run_preview() {
557 let commands = vec![
558 "docker pull nginx".to_string(),
559 "docker run -d nginx".to_string(),
560 ];
561
562 let preview = DryRunPreview::new(commands);
563 let output = preview.to_string();
564
565 assert!(output.contains("Would execute"));
566 assert!(output.contains("1. docker pull nginx"));
567 assert!(output.contains("2. docker run -d nginx"));
568 }
569}