1use crate::command::{CommandExecutor, CommandOutput};
4use crate::error::Result;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7use tokio::time::sleep;
8
9#[derive(Debug, Clone)]
11pub struct DebugConfig {
12 pub dry_run: bool,
14
15 pub verbose: bool,
17
18 pub command_log: Arc<Mutex<Vec<String>>>,
20
21 pub dry_run_prefix: String,
23}
24
25impl Default for DebugConfig {
26 fn default() -> Self {
27 Self {
28 dry_run: false,
29 verbose: false,
30 command_log: Arc::new(Mutex::new(Vec::new())),
31 dry_run_prefix: "[DRY RUN]".to_string(),
32 }
33 }
34}
35
36impl DebugConfig {
37 #[must_use]
39 pub fn new() -> Self {
40 Self::default()
41 }
42
43 #[must_use]
45 pub fn dry_run(mut self, enabled: bool) -> Self {
46 self.dry_run = enabled;
47 self
48 }
49
50 #[must_use]
52 pub fn verbose(mut self, enabled: bool) -> Self {
53 self.verbose = enabled;
54 self
55 }
56
57 #[must_use]
59 pub fn dry_run_prefix(mut self, prefix: impl Into<String>) -> Self {
60 self.dry_run_prefix = prefix.into();
61 self
62 }
63
64 pub fn log_command(&self, command: &str) {
66 if let Ok(mut log) = self.command_log.lock() {
67 log.push(command.to_string());
68 }
69 }
70
71 #[must_use]
73 pub fn get_command_log(&self) -> Vec<String> {
74 self.command_log
75 .lock()
76 .map(|log| log.clone())
77 .unwrap_or_default()
78 }
79
80 pub fn clear_log(&self) {
82 if let Ok(mut log) = self.command_log.lock() {
83 log.clear();
84 }
85 }
86}
87
88pub type RetryCallback = Arc<dyn Fn(u32, &str) + Send + Sync>;
90
91#[derive(Clone)]
93pub struct RetryPolicy {
94 pub max_attempts: u32,
96
97 pub backoff: BackoffStrategy,
99
100 pub on_retry: Option<RetryCallback>,
102}
103
104impl std::fmt::Debug for RetryPolicy {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("RetryPolicy")
107 .field("max_attempts", &self.max_attempts)
108 .field("backoff", &self.backoff)
109 .field("on_retry", &self.on_retry.is_some())
110 .finish()
111 }
112}
113
114#[derive(Debug, Clone)]
116pub enum BackoffStrategy {
117 Fixed(Duration),
119
120 Linear {
122 initial: Duration,
124 increment: Duration,
126 },
127
128 Exponential {
130 initial: Duration,
132 max: Duration,
134 multiplier: f64,
136 },
137}
138
139impl Default for RetryPolicy {
140 fn default() -> Self {
141 Self {
142 max_attempts: 3,
143 backoff: BackoffStrategy::Exponential {
144 initial: Duration::from_millis(100),
145 max: Duration::from_secs(10),
146 multiplier: 2.0,
147 },
148 on_retry: None,
149 }
150 }
151}
152
153impl RetryPolicy {
154 #[must_use]
156 pub fn new() -> Self {
157 Self::default()
158 }
159
160 #[must_use]
162 pub fn max_attempts(mut self, attempts: u32) -> Self {
163 self.max_attempts = attempts;
164 self
165 }
166
167 #[must_use]
169 pub fn backoff(mut self, strategy: BackoffStrategy) -> Self {
170 self.backoff = strategy;
171 self
172 }
173
174 #[must_use]
176 pub fn on_retry<F>(mut self, callback: F) -> Self
177 where
178 F: Fn(u32, &str) + Send + Sync + 'static,
179 {
180 self.on_retry = Some(Arc::new(callback));
181 self
182 }
183
184 #[must_use]
186 pub fn calculate_delay(&self, attempt: u32) -> Duration {
187 match &self.backoff {
188 BackoffStrategy::Fixed(delay) => *delay,
189
190 BackoffStrategy::Linear { initial, increment } => {
191 *initial + (*increment * (attempt - 1))
192 }
193
194 BackoffStrategy::Exponential {
195 initial,
196 max,
197 multiplier,
198 } => {
199 #[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
200 let delay_ms =
201 initial.as_millis() as f64 * multiplier.powi(attempt.saturating_sub(1) as i32);
202 #[allow(clippy::cast_precision_loss)]
203 let capped_ms = delay_ms.min(max.as_millis() as f64);
204 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
205 Duration::from_millis(capped_ms as u64)
206 }
207 }
208 }
209
210 #[must_use]
212 pub fn is_retryable(error_str: &str) -> bool {
213 error_str.contains("connection refused")
215 || error_str.contains("timeout")
216 || error_str.contains("temporarily unavailable")
217 || error_str.contains("resource temporarily unavailable")
218 || error_str.contains("Cannot connect to the Docker daemon")
219 || error_str.contains("503 Service Unavailable")
220 || error_str.contains("502 Bad Gateway")
221 }
222}
223
224#[derive(Debug, Clone)]
226pub struct DebugExecutor {
227 pub executor: CommandExecutor,
229
230 pub debug_config: DebugConfig,
232
233 pub retry_policy: Option<RetryPolicy>,
235}
236
237impl DebugExecutor {
238 #[must_use]
240 pub fn new() -> Self {
241 Self {
242 executor: CommandExecutor::new(),
243 debug_config: DebugConfig::default(),
244 retry_policy: None,
245 }
246 }
247
248 #[must_use]
250 pub fn dry_run(mut self, enabled: bool) -> Self {
251 self.debug_config = self.debug_config.dry_run(enabled);
252 self
253 }
254
255 #[must_use]
257 pub fn verbose(mut self, enabled: bool) -> Self {
258 self.debug_config = self.debug_config.verbose(enabled);
259 self
260 }
261
262 #[must_use]
264 pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
265 self.retry_policy = Some(policy);
266 self
267 }
268
269 pub async fn execute_command(
275 &self,
276 command_name: &str,
277 args: Vec<String>,
278 ) -> Result<CommandOutput> {
279 let command_str = format!("docker {} {}", command_name, args.join(" "));
280
281 self.debug_config.log_command(&command_str);
283
284 if self.debug_config.verbose {
286 eprintln!("[VERBOSE] Executing: {command_str}");
287 }
288
289 if self.debug_config.dry_run {
291 let message = format!(
292 "{} Would execute: {}",
293 self.debug_config.dry_run_prefix, command_str
294 );
295 eprintln!("{message}");
296
297 return Ok(CommandOutput {
298 stdout: message,
299 stderr: String::new(),
300 exit_code: 0,
301 success: true,
302 });
303 }
304
305 if let Some(ref policy) = self.retry_policy {
307 self.execute_with_retry(command_name, args, policy).await
308 } else {
309 self.executor.execute_command(command_name, args).await
310 }
311 }
312
313 async fn execute_with_retry(
315 &self,
316 command_name: &str,
317 args: Vec<String>,
318 policy: &RetryPolicy,
319 ) -> Result<CommandOutput> {
320 let mut attempt = 0;
321 let mut last_error = None;
322
323 while attempt < policy.max_attempts {
324 attempt += 1;
325
326 if self.debug_config.verbose && attempt > 1 {
327 eprintln!(
328 "[VERBOSE] Retry attempt {}/{}",
329 attempt, policy.max_attempts
330 );
331 }
332
333 match self
334 .executor
335 .execute_command(command_name, args.clone())
336 .await
337 {
338 Ok(output) => return Ok(output),
339 Err(e) => {
340 let error_str = e.to_string();
341
342 if !RetryPolicy::is_retryable(&error_str) {
344 return Err(e);
345 }
346
347 if attempt >= policy.max_attempts {
349 return Err(e);
350 }
351
352 if let Some(ref callback) = policy.on_retry {
354 callback(attempt, &error_str);
355 }
356
357 let delay = policy.calculate_delay(attempt);
359 if self.debug_config.verbose {
360 eprintln!("[VERBOSE] Waiting {delay:?} before retry");
361 }
362 sleep(delay).await;
363
364 last_error = Some(e);
365 }
366 }
367 }
368
369 Err(last_error.unwrap_or_else(|| crate::error::Error::custom("Retry failed")))
370 }
371
372 #[must_use]
374 pub fn get_command_log(&self) -> Vec<String> {
375 self.debug_config.get_command_log()
376 }
377
378 pub fn clear_log(&self) {
380 self.debug_config.clear_log();
381 }
382}
383
384impl Default for DebugExecutor {
385 fn default() -> Self {
386 Self::new()
387 }
388}
389
390pub struct DryRunPreview {
392 pub commands: Vec<String>,
394}
395
396impl DryRunPreview {
397 #[must_use]
399 pub fn new(commands: Vec<String>) -> Self {
400 Self { commands }
401 }
402
403 pub fn print(&self) {
405 if self.commands.is_empty() {
406 println!("No commands would be executed.");
407 return;
408 }
409
410 println!("Would execute the following commands:");
411 for (i, cmd) in self.commands.iter().enumerate() {
412 println!(" {}. {}", i + 1, cmd);
413 }
414 }
415}
416
417impl std::fmt::Display for DryRunPreview {
418 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419 if self.commands.is_empty() {
420 return write!(f, "No commands would be executed.");
421 }
422
423 writeln!(f, "Would execute the following commands:")?;
424 for (i, cmd) in self.commands.iter().enumerate() {
425 writeln!(f, " {}. {}", i + 1, cmd)?;
426 }
427 Ok(())
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn test_debug_config() {
437 let config = DebugConfig::new()
438 .dry_run(true)
439 .verbose(true)
440 .dry_run_prefix("[TEST]");
441
442 assert!(config.dry_run);
443 assert!(config.verbose);
444 assert_eq!(config.dry_run_prefix, "[TEST]");
445 }
446
447 #[test]
448 fn test_retry_policy_delay() {
449 let policy = RetryPolicy::new().backoff(BackoffStrategy::Fixed(Duration::from_millis(100)));
451 assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
452 assert_eq!(policy.calculate_delay(3), Duration::from_millis(100));
453
454 let policy = RetryPolicy::new().backoff(BackoffStrategy::Linear {
456 initial: Duration::from_millis(100),
457 increment: Duration::from_millis(50),
458 });
459 assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
460 assert_eq!(policy.calculate_delay(2), Duration::from_millis(150));
461 assert_eq!(policy.calculate_delay(3), Duration::from_millis(200));
462
463 let policy = RetryPolicy::new().backoff(BackoffStrategy::Exponential {
465 initial: Duration::from_millis(100),
466 max: Duration::from_secs(1),
467 multiplier: 2.0,
468 });
469 assert_eq!(policy.calculate_delay(1), Duration::from_millis(100));
470 assert_eq!(policy.calculate_delay(2), Duration::from_millis(200));
471 assert_eq!(policy.calculate_delay(3), Duration::from_millis(400));
472 assert_eq!(policy.calculate_delay(5), Duration::from_secs(1)); }
474
475 #[test]
476 fn test_retryable_errors() {
477 assert!(RetryPolicy::is_retryable("connection refused"));
478 assert!(RetryPolicy::is_retryable("operation timeout"));
479 assert!(RetryPolicy::is_retryable(
480 "Cannot connect to the Docker daemon"
481 ));
482 assert!(!RetryPolicy::is_retryable("image not found"));
483 assert!(!RetryPolicy::is_retryable("permission denied"));
484 }
485
486 #[test]
487 fn test_command_logging() {
488 let config = DebugConfig::new();
489 config.log_command("docker ps -a");
490 config.log_command("docker run nginx");
491
492 let log = config.get_command_log();
493 assert_eq!(log.len(), 2);
494 assert_eq!(log[0], "docker ps -a");
495 assert_eq!(log[1], "docker run nginx");
496
497 config.clear_log();
498 assert_eq!(config.get_command_log().len(), 0);
499 }
500
501 #[test]
502 fn test_dry_run_preview() {
503 let commands = vec![
504 "docker pull nginx".to_string(),
505 "docker run -d nginx".to_string(),
506 ];
507
508 let preview = DryRunPreview::new(commands);
509 let output = preview.to_string();
510
511 assert!(output.contains("Would execute"));
512 assert!(output.contains("1. docker pull nginx"));
513 assert!(output.contains("2. docker run -d nginx"));
514 }
515}