docker_wrapper/
debug.rs

1//! Debugging and reliability features for Docker commands.
2
3use crate::command::{CommandExecutor, CommandOutput};
4use crate::error::Result;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7use tokio::time::sleep;
8
9/// Configuration for dry-run mode and debugging
10#[derive(Debug, Clone)]
11pub struct DebugConfig {
12    /// Enable dry-run mode (commands are not executed)
13    pub dry_run: bool,
14
15    /// Enable verbose output
16    pub verbose: bool,
17
18    /// Log all commands to this vector
19    pub command_log: Arc<Mutex<Vec<String>>>,
20
21    /// Custom prefix for dry-run output
22    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    /// Create a new debug configuration
38    #[must_use]
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Enable dry-run mode
44    #[must_use]
45    pub fn dry_run(mut self, enabled: bool) -> Self {
46        self.dry_run = enabled;
47        self
48    }
49
50    /// Enable verbose output
51    #[must_use]
52    pub fn verbose(mut self, enabled: bool) -> Self {
53        self.verbose = enabled;
54        self
55    }
56
57    /// Set custom dry-run prefix
58    #[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    /// Log a command
65    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    /// Get logged commands
72    #[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    /// Clear command log
81    pub fn clear_log(&self) {
82        if let Ok(mut log) = self.command_log.lock() {
83            log.clear();
84        }
85    }
86}
87
88/// Type alias for retry callback function
89pub type RetryCallback = Arc<dyn Fn(u32, &str) + Send + Sync>;
90
91/// Retry policy for handling transient failures
92#[derive(Clone)]
93pub struct RetryPolicy {
94    /// Maximum number of attempts
95    pub max_attempts: u32,
96
97    /// Backoff strategy between retries
98    pub backoff: BackoffStrategy,
99
100    /// Callback for retry events
101    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/// Backoff strategy for retries
115#[derive(Debug, Clone)]
116pub enum BackoffStrategy {
117    /// Fixed delay between retries
118    Fixed(Duration),
119
120    /// Linear increase in delay
121    Linear {
122        /// Initial delay duration
123        initial: Duration,
124        /// Increment added for each retry
125        increment: Duration,
126    },
127
128    /// Exponential backoff
129    Exponential {
130        /// Initial delay duration
131        initial: Duration,
132        /// Maximum delay duration
133        max: Duration,
134        /// Multiplier for exponential growth
135        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    /// Create a new retry policy
155    #[must_use]
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    /// Set maximum attempts
161    #[must_use]
162    pub fn max_attempts(mut self, attempts: u32) -> Self {
163        self.max_attempts = attempts;
164        self
165    }
166
167    /// Set backoff strategy
168    #[must_use]
169    pub fn backoff(mut self, strategy: BackoffStrategy) -> Self {
170        self.backoff = strategy;
171        self
172    }
173
174    /// Set retry callback
175    #[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    /// Calculate delay for attempt number
185    #[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    /// Check if an error is retryable
211    #[must_use]
212    pub fn is_retryable(error_str: &str) -> bool {
213        // Common retryable Docker errors
214        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/// Enhanced command executor with debugging features
225#[derive(Debug, Clone)]
226pub struct DebugExecutor {
227    /// Base executor
228    pub executor: CommandExecutor,
229
230    /// Debug configuration
231    pub debug_config: DebugConfig,
232
233    /// Retry policy
234    pub retry_policy: Option<RetryPolicy>,
235}
236
237impl DebugExecutor {
238    /// Create a new debug executor
239    #[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    /// Enable dry-run mode
249    #[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    /// Enable verbose mode
256    #[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    /// Set retry policy
263    #[must_use]
264    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
265        self.retry_policy = Some(policy);
266        self
267    }
268
269    /// Execute a command with debug features
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if the command fails after all retry attempts
274    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        // Log the command
282        self.debug_config.log_command(&command_str);
283
284        // Verbose output
285        if self.debug_config.verbose {
286            eprintln!("[VERBOSE] Executing: {command_str}");
287        }
288
289        // Dry-run mode
290        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        // Execute with retry if configured
306        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    /// Execute command with retry logic
314    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                    // Check if retryable
343                    if !RetryPolicy::is_retryable(&error_str) {
344                        return Err(e);
345                    }
346
347                    // Last attempt?
348                    if attempt >= policy.max_attempts {
349                        return Err(e);
350                    }
351
352                    // Call retry callback
353                    if let Some(ref callback) = policy.on_retry {
354                        callback(attempt, &error_str);
355                    }
356
357                    // Calculate and apply delay
358                    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    /// Get command history
373    #[must_use]
374    pub fn get_command_log(&self) -> Vec<String> {
375        self.debug_config.get_command_log()
376    }
377
378    /// Clear command history
379    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
390/// Helper for previewing commands without executing them
391pub struct DryRunPreview {
392    /// Commands that would be executed
393    pub commands: Vec<String>,
394}
395
396impl DryRunPreview {
397    /// Create a preview of commands
398    #[must_use]
399    pub fn new(commands: Vec<String>) -> Self {
400        Self { commands }
401    }
402
403    /// Print the preview
404    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        // Fixed backoff
450        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        // Linear backoff
455        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        // Exponential backoff
464        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)); // Capped at max
473    }
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}