docker_wrapper/
testing.rs

1//! # Testing Utilities
2//!
3//! RAII-style container lifecycle management for integration tests.
4//!
5//! This module provides [`ContainerGuard`] and [`ContainerGuardSet`] for automatic
6//! container lifecycle management. Containers are automatically stopped and removed
7//! when guards go out of scope, ensuring clean test environments.
8//!
9//! ## Why Use This?
10//!
11//! - **Automatic cleanup**: No more forgotten containers cluttering your Docker
12//! - **Panic-safe**: Containers are cleaned up even if your test panics
13//! - **Debug-friendly**: Keep containers alive on failure for inspection
14//! - **Network support**: Automatic network creation for multi-container tests
15//! - **Ready checks**: Wait for services to be ready before running tests
16//!
17//! ## Quick Start
18//!
19//! ```rust,no_run
20//! use docker_wrapper::testing::ContainerGuard;
21//! use docker_wrapper::RedisTemplate;
22//!
23//! #[tokio::test]
24//! async fn test_with_redis() -> Result<(), Box<dyn std::error::Error>> {
25//!     // Container starts and waits for Redis to be ready
26//!     let guard = ContainerGuard::new(RedisTemplate::new("test-redis"))
27//!         .wait_for_ready(true)
28//!         .start()
29//!         .await?;
30//!
31//!     // Get connection string directly from guard
32//!     let url = guard.connection_string();
33//!     // Use Redis at: redis://localhost:6379
34//!
35//!     Ok(())
36//!     // Container automatically stopped and removed here
37//! }
38//! ```
39//!
40//! ## Configuration Options
41//!
42//! ### Lifecycle Control
43//!
44//! ```rust,no_run
45//! # use docker_wrapper::testing::ContainerGuard;
46//! # use docker_wrapper::RedisTemplate;
47//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
48//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
49//!     .stop_on_drop(true)      // Stop container on drop (default: true)
50//!     .remove_on_drop(true)    // Remove container on drop (default: true)
51//!     .start()
52//!     .await?;
53//! # Ok(())
54//! # }
55//! ```
56//!
57//! ### Debugging Failed Tests
58//!
59//! Keep containers running when tests fail for debugging:
60//!
61//! ```rust,no_run
62//! # use docker_wrapper::testing::ContainerGuard;
63//! # use docker_wrapper::RedisTemplate;
64//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
65//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
66//!     .keep_on_panic(true)     // Keep container if test panics
67//!     .capture_logs(true)      // Print container logs on panic
68//!     .start()
69//!     .await?;
70//! # Ok(())
71//! # }
72//! ```
73//!
74//! ### Ready Checks
75//!
76//! Wait for the service to be ready before proceeding:
77//!
78//! ```rust,no_run
79//! # use docker_wrapper::testing::ContainerGuard;
80//! # use docker_wrapper::RedisTemplate;
81//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
82//! // Automatic wait during start
83//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
84//!     .wait_for_ready(true)
85//!     .start()
86//!     .await?;
87//! // Redis is guaranteed ready here
88//!
89//! // Or wait manually later
90//! let guard2 = ContainerGuard::new(RedisTemplate::new("redis2"))
91//!     .start()
92//!     .await?;
93//! guard2.wait_for_ready().await?;
94//! # Ok(())
95//! # }
96//! ```
97//!
98//! ### Container Reuse
99//!
100//! Speed up local development by reusing running containers:
101//!
102//! ```rust,no_run
103//! # use docker_wrapper::testing::ContainerGuard;
104//! # use docker_wrapper::RedisTemplate;
105//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
106//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
107//!     .reuse_if_running(true)  // Reuse existing container if found
108//!     .remove_on_drop(false)   // Keep it for next test run
109//!     .stop_on_drop(false)
110//!     .start()
111//!     .await?;
112//!
113//! if guard.was_reused() {
114//!     println!("Reused existing container");
115//! }
116//! # Ok(())
117//! # }
118//! ```
119//!
120//! ### Network Support
121//!
122//! Attach containers to custom networks:
123//!
124//! ```rust,no_run
125//! # use docker_wrapper::testing::ContainerGuard;
126//! # use docker_wrapper::RedisTemplate;
127//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
128//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
129//!     .with_network("my-test-network")  // Create and attach to network
130//!     .remove_network_on_drop(true)     // Clean up network after test
131//!     .start()
132//!     .await?;
133//! # Ok(())
134//! # }
135//! ```
136//!
137//! ### Fast Cleanup
138//!
139//! Use a short stop timeout for faster test cleanup:
140//!
141//! ```rust,no_run
142//! # use docker_wrapper::testing::ContainerGuard;
143//! # use docker_wrapper::RedisTemplate;
144//! # use std::time::Duration;
145//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
146//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
147//!     .stop_timeout(Duration::from_secs(1))  // 1 second graceful shutdown
148//!     .start()
149//!     .await?;
150//!
151//! // Or immediate SIGKILL
152//! let guard2 = ContainerGuard::new(RedisTemplate::new("redis2"))
153//!     .stop_timeout(Duration::ZERO)
154//!     .start()
155//!     .await?;
156//! # Ok(())
157//! # }
158//! ```
159//!
160//! ## Multi-Container Tests
161//!
162//! Use [`ContainerGuardSet`] for tests requiring multiple services:
163//!
164//! ```rust,no_run
165//! use docker_wrapper::testing::ContainerGuardSet;
166//! use docker_wrapper::RedisTemplate;
167//!
168//! #[tokio::test]
169//! async fn test_multi_container() -> Result<(), Box<dyn std::error::Error>> {
170//!     let guards = ContainerGuardSet::new()
171//!         .with_network("test-network")    // Shared network for all containers
172//!         .add(RedisTemplate::new("redis-primary").port(6379))
173//!         .add(RedisTemplate::new("redis-replica").port(6380))
174//!         .keep_on_panic(true)
175//!         .start_all()
176//!         .await?;
177//!
178//!     assert!(guards.contains("redis-primary"));
179//!     assert!(guards.contains("redis-replica"));
180//!     assert_eq!(guards.len(), 2);
181//!
182//!     // All containers cleaned up together
183//!     Ok(())
184//! }
185//! ```
186//!
187//! ## Accessing Container Information
188//!
189//! ```rust,no_run
190//! # use docker_wrapper::testing::ContainerGuard;
191//! # use docker_wrapper::RedisTemplate;
192//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
193//! let guard = ContainerGuard::new(RedisTemplate::new("redis").port(6379))
194//!     .start()
195//!     .await?;
196//!
197//! // Connection string (for templates that support it)
198//! let conn = guard.connection_string();
199//!
200//! // Access underlying template
201//! let template = guard.template();
202//!
203//! // Get container ID
204//! if let Some(id) = guard.container_id() {
205//!     println!("Container ID: {}", id);
206//! }
207//!
208//! // Query host port for a container port
209//! let host_port = guard.host_port(6379).await?;
210//!
211//! // Get container logs
212//! let logs = guard.logs().await?;
213//!
214//! // Check if running
215//! let running = guard.is_running().await?;
216//! # Ok(())
217//! # }
218//! ```
219//!
220//! ## Common Patterns
221//!
222//! ### Test Fixtures
223//!
224//! Create reusable test fixtures:
225//!
226//! ```rust,no_run
227//! use docker_wrapper::testing::ContainerGuard;
228//! use docker_wrapper::RedisTemplate;
229//! use docker_wrapper::template::TemplateError;
230//!
231//! async fn redis_fixture(name: &str) -> Result<ContainerGuard<RedisTemplate>, TemplateError> {
232//!     ContainerGuard::new(RedisTemplate::new(name))
233//!         .wait_for_ready(true)
234//!         .keep_on_panic(true)
235//!         .capture_logs(true)
236//!         .start()
237//!         .await
238//! }
239//!
240//! #[tokio::test]
241//! async fn test_using_fixture() -> Result<(), Box<dyn std::error::Error>> {
242//!     let redis = redis_fixture("test-redis").await?;
243//!     // Use redis...
244//!     Ok(())
245//! }
246//! ```
247//!
248//! ### Unique Container Names
249//!
250//! Use UUIDs to avoid name conflicts in parallel tests:
251//!
252//! ```rust,no_run
253//! # use docker_wrapper::testing::ContainerGuard;
254//! # use docker_wrapper::RedisTemplate;
255//! fn unique_name(prefix: &str) -> String {
256//!     format!("{}-{}", prefix, uuid::Uuid::new_v4())
257//! }
258//!
259//! #[tokio::test]
260//! async fn test_parallel_safe() -> Result<(), Box<dyn std::error::Error>> {
261//!     let name = unique_name("redis");
262//!     let guard = ContainerGuard::new(RedisTemplate::new(&name))
263//!         .start()
264//!         .await?;
265//!     Ok(())
266//! }
267//! ```
268//!
269//! ### Manual Cleanup
270//!
271//! Trigger cleanup explicitly when needed:
272//!
273//! ```rust,no_run
274//! # use docker_wrapper::testing::ContainerGuard;
275//! # use docker_wrapper::RedisTemplate;
276//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
277//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
278//!     .start()
279//!     .await?;
280//!
281//! // Do some work...
282//!
283//! // Explicitly cleanup (idempotent - safe to call multiple times)
284//! guard.cleanup().await?;
285//!
286//! // Drop will not try to clean up again
287//! # Ok(())
288//! # }
289//! ```
290//!
291//! ## Feature Flag
292//!
293//! This module requires the `testing` feature:
294//!
295//! ```toml
296//! [dev-dependencies]
297//! docker-wrapper = { version = "0.10", features = ["testing", "template-redis"] }
298//! ```
299
300use crate::command::DockerCommand;
301use crate::template::{HasConnectionString, Template, TemplateError};
302use crate::{
303    LogsCommand, NetworkCreateCommand, NetworkRmCommand, PortCommand, RmCommand, StopCommand,
304};
305use std::collections::HashMap;
306use std::sync::atomic::{AtomicBool, Ordering};
307use std::sync::Arc;
308use std::time::Duration;
309
310/// Options for controlling container lifecycle behavior.
311#[derive(Debug, Clone)]
312#[allow(clippy::struct_excessive_bools)]
313pub struct GuardOptions {
314    /// Remove container on drop (default: true)
315    pub remove_on_drop: bool,
316    /// Stop container on drop (default: true)
317    pub stop_on_drop: bool,
318    /// Keep container running if test panics (default: false)
319    pub keep_on_panic: bool,
320    /// Capture container logs and print on panic (default: false)
321    pub capture_logs: bool,
322    /// Reuse existing container if already running (default: false)
323    pub reuse_if_running: bool,
324    /// Automatically wait for container to be ready after start (default: false)
325    pub wait_for_ready: bool,
326    /// Network to attach the container to (default: None)
327    pub network: Option<String>,
328    /// Create the network if it doesn't exist (default: true when network is set)
329    pub create_network: bool,
330    /// Remove the network on drop (default: false)
331    pub remove_network_on_drop: bool,
332    /// Timeout for stop operations during cleanup (default: None, uses Docker default)
333    pub stop_timeout: Option<Duration>,
334}
335
336impl Default for GuardOptions {
337    fn default() -> Self {
338        Self {
339            remove_on_drop: true,
340            stop_on_drop: true,
341            keep_on_panic: false,
342            capture_logs: false,
343            reuse_if_running: false,
344            wait_for_ready: false,
345            network: None,
346            create_network: true,
347            remove_network_on_drop: false,
348            stop_timeout: None,
349        }
350    }
351}
352
353/// Builder for creating a [`ContainerGuard`] with custom options.
354pub struct ContainerGuardBuilder<T: Template> {
355    template: T,
356    options: GuardOptions,
357}
358
359impl<T: Template> ContainerGuardBuilder<T> {
360    /// Create a new builder with the given template.
361    #[must_use]
362    pub fn new(template: T) -> Self {
363        Self {
364            template,
365            options: GuardOptions::default(),
366        }
367    }
368
369    /// Set whether to remove the container on drop (default: true).
370    #[must_use]
371    pub fn remove_on_drop(mut self, remove: bool) -> Self {
372        self.options.remove_on_drop = remove;
373        self
374    }
375
376    /// Set whether to stop the container on drop (default: true).
377    #[must_use]
378    pub fn stop_on_drop(mut self, stop: bool) -> Self {
379        self.options.stop_on_drop = stop;
380        self
381    }
382
383    /// Set whether to keep the container running if the test panics (default: false).
384    ///
385    /// This is useful for debugging failed tests - you can inspect the container
386    /// state after the test fails.
387    #[must_use]
388    pub fn keep_on_panic(mut self, keep: bool) -> Self {
389        self.options.keep_on_panic = keep;
390        self
391    }
392
393    /// Set whether to capture container logs and print them on panic (default: false).
394    ///
395    /// When enabled, container logs are buffered and printed to stderr if the
396    /// test panics, making it easier to debug failures.
397    #[must_use]
398    pub fn capture_logs(mut self, capture: bool) -> Self {
399        self.options.capture_logs = capture;
400        self
401    }
402
403    /// Set whether to reuse an existing container if already running (default: false).
404    ///
405    /// This is useful for faster local development iteration - containers can
406    /// be kept running between test runs.
407    #[must_use]
408    pub fn reuse_if_running(mut self, reuse: bool) -> Self {
409        self.options.reuse_if_running = reuse;
410        self
411    }
412
413    /// Set whether to automatically wait for the container to be ready after starting (default: false).
414    ///
415    /// When enabled, `start()` will not return until the container passes its
416    /// readiness check. This is useful for tests that need to immediately connect
417    /// to the service.
418    ///
419    /// # Example
420    ///
421    /// ```rust,no_run
422    /// # use docker_wrapper::testing::ContainerGuard;
423    /// # use docker_wrapper::RedisTemplate;
424    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
425    /// let guard = ContainerGuard::new(RedisTemplate::new("test"))
426    ///     .wait_for_ready(true)
427    ///     .start()
428    ///     .await?;
429    /// // Container is guaranteed ready at this point
430    /// # Ok(())
431    /// # }
432    /// ```
433    #[must_use]
434    pub fn wait_for_ready(mut self, wait: bool) -> Self {
435        self.options.wait_for_ready = wait;
436        self
437    }
438
439    /// Attach the container to a Docker network.
440    ///
441    /// By default, the network will be created if it doesn't exist. Use
442    /// `create_network(false)` to disable automatic network creation.
443    ///
444    /// # Example
445    ///
446    /// ```rust,no_run
447    /// # use docker_wrapper::testing::ContainerGuard;
448    /// # use docker_wrapper::RedisTemplate;
449    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
450    /// let guard = ContainerGuard::new(RedisTemplate::new("redis"))
451    ///     .with_network("test-network")
452    ///     .start()
453    ///     .await?;
454    /// // Container is attached to "test-network"
455    /// # Ok(())
456    /// # }
457    /// ```
458    #[must_use]
459    pub fn with_network(mut self, network: impl Into<String>) -> Self {
460        self.options.network = Some(network.into());
461        self
462    }
463
464    /// Set whether to create the network if it doesn't exist (default: true).
465    ///
466    /// Only applies when a network is specified via `with_network()`.
467    #[must_use]
468    pub fn create_network(mut self, create: bool) -> Self {
469        self.options.create_network = create;
470        self
471    }
472
473    /// Set whether to remove the network on drop (default: false).
474    ///
475    /// This is useful for cleaning up test-specific networks. Only applies
476    /// when a network is specified via `with_network()`.
477    ///
478    /// Note: The network removal will fail silently if other containers are
479    /// still using it.
480    #[must_use]
481    pub fn remove_network_on_drop(mut self, remove: bool) -> Self {
482        self.options.remove_network_on_drop = remove;
483        self
484    }
485
486    /// Set the timeout for stop operations during cleanup (default: Docker default).
487    ///
488    /// This controls how long Docker waits for the container to stop gracefully
489    /// before sending SIGKILL.
490    ///
491    /// # Example
492    ///
493    /// ```rust,no_run
494    /// # use docker_wrapper::testing::ContainerGuard;
495    /// # use docker_wrapper::RedisTemplate;
496    /// # use std::time::Duration;
497    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
498    /// // Fast cleanup with 1 second timeout
499    /// let guard = ContainerGuard::new(RedisTemplate::new("redis"))
500    ///     .stop_timeout(Duration::from_secs(1))
501    ///     .start()
502    ///     .await?;
503    ///
504    /// // Immediate SIGKILL with zero timeout
505    /// let guard = ContainerGuard::new(RedisTemplate::new("redis2"))
506    ///     .stop_timeout(Duration::ZERO)
507    ///     .start()
508    ///     .await?;
509    /// # Ok(())
510    /// # }
511    /// ```
512    #[must_use]
513    pub fn stop_timeout(mut self, timeout: Duration) -> Self {
514        self.options.stop_timeout = Some(timeout);
515        self
516    }
517
518    /// Start the container and return a guard that manages its lifecycle.
519    ///
520    /// If `reuse_if_running` is enabled and a container is already running,
521    /// it will be reused instead of starting a new one.
522    ///
523    /// If `wait_for_ready` is enabled, this method will block until the
524    /// container passes its readiness check.
525    ///
526    /// If a network is specified via `with_network()`, the container will be
527    /// attached to that network. The network will be created if it doesn't
528    /// exist (unless `create_network(false)` was called).
529    ///
530    /// # Errors
531    ///
532    /// Returns an error if the container fails to start or the readiness check times out.
533    pub async fn start(mut self) -> Result<ContainerGuard<T>, TemplateError> {
534        let wait_for_ready = self.options.wait_for_ready;
535        let mut network_created = false;
536
537        // Create network if specified and create_network is enabled
538        if let Some(ref network) = self.options.network {
539            if self.options.create_network {
540                // Try to create the network (ignore errors if it already exists)
541                let result = NetworkCreateCommand::new(network)
542                    .driver("bridge")
543                    .execute()
544                    .await;
545
546                // Track if we successfully created it (for cleanup purposes)
547                network_created = result.is_ok();
548            }
549
550            // Set the network on the template
551            self.template.config_mut().network = Some(network.clone());
552        }
553
554        // Check if we should reuse an existing container
555        if self.options.reuse_if_running {
556            if let Ok(true) = self.template.is_running().await {
557                let guard = ContainerGuard {
558                    template: self.template,
559                    container_id: None, // We don't have the ID for reused containers
560                    options: self.options,
561                    was_reused: true,
562                    network_created,
563                    cleaned_up: Arc::new(AtomicBool::new(false)),
564                };
565
566                // Wait for ready if configured (even for reused containers)
567                if wait_for_ready {
568                    guard.wait_for_ready().await?;
569                }
570
571                return Ok(guard);
572            }
573        }
574
575        // Start the container
576        let container_id = self.template.start_and_wait().await?;
577
578        let guard = ContainerGuard {
579            template: self.template,
580            container_id: Some(container_id),
581            options: self.options,
582            was_reused: false,
583            network_created,
584            cleaned_up: Arc::new(AtomicBool::new(false)),
585        };
586
587        // Wait for ready if configured
588        if wait_for_ready {
589            guard.wait_for_ready().await?;
590        }
591
592        Ok(guard)
593    }
594}
595
596/// RAII guard for automatic container lifecycle management.
597///
598/// When this guard is dropped, the container is automatically stopped and
599/// removed (unless configured otherwise via [`ContainerGuardBuilder`]).
600///
601/// # Example
602///
603/// ```rust,no_run
604/// use docker_wrapper::testing::ContainerGuard;
605/// use docker_wrapper::RedisTemplate;
606///
607/// #[tokio::test]
608/// async fn test_example() -> Result<(), Box<dyn std::error::Error>> {
609///     let guard = ContainerGuard::new(RedisTemplate::new("test"))
610///         .keep_on_panic(true)  // Keep container for debugging if test fails
611///         .capture_logs(true)   // Print logs on failure
612///         .start()
613///         .await?;
614///
615///     // Container is automatically cleaned up when guard goes out of scope
616///     Ok(())
617/// }
618/// ```
619pub struct ContainerGuard<T: Template> {
620    template: T,
621    container_id: Option<String>,
622    options: GuardOptions,
623    was_reused: bool,
624    network_created: bool,
625    cleaned_up: Arc<AtomicBool>,
626}
627
628impl<T: Template> ContainerGuard<T> {
629    /// Create a new builder for a container guard.
630    ///
631    /// Note: This returns a builder, not a `ContainerGuard`. Call `.start().await`
632    /// on the builder to create the guard.
633    #[allow(clippy::new_ret_no_self)]
634    pub fn new(template: T) -> ContainerGuardBuilder<T> {
635        ContainerGuardBuilder::new(template)
636    }
637
638    /// Get a reference to the underlying template.
639    #[must_use]
640    pub fn template(&self) -> &T {
641        &self.template
642    }
643
644    /// Get the container ID, if available.
645    ///
646    /// This may be `None` if the container was reused from a previous run.
647    #[must_use]
648    pub fn container_id(&self) -> Option<&str> {
649        self.container_id.as_deref()
650    }
651
652    /// Check if this guard is reusing an existing container.
653    #[must_use]
654    pub fn was_reused(&self) -> bool {
655        self.was_reused
656    }
657
658    /// Get the network name, if one was configured.
659    #[must_use]
660    pub fn network(&self) -> Option<&str> {
661        self.options.network.as_deref()
662    }
663
664    /// Check if the container is currently running.
665    ///
666    /// # Errors
667    ///
668    /// Returns an error if the Docker command fails.
669    pub async fn is_running(&self) -> Result<bool, TemplateError> {
670        self.template.is_running().await
671    }
672
673    /// Wait for the container to be ready.
674    ///
675    /// This calls the underlying template's readiness check. The exact behavior
676    /// depends on the template implementation - for example, Redis templates
677    /// wait for a successful PING response.
678    ///
679    /// # Errors
680    ///
681    /// Returns an error if the readiness check times out or the Docker command fails.
682    ///
683    /// # Example
684    ///
685    /// ```rust,no_run
686    /// # use docker_wrapper::testing::ContainerGuard;
687    /// # use docker_wrapper::RedisTemplate;
688    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
689    /// let guard = ContainerGuard::new(RedisTemplate::new("test"))
690    ///     .start()
691    ///     .await?;
692    ///
693    /// // Wait for Redis to be ready to accept connections
694    /// guard.wait_for_ready().await?;
695    /// # Ok(())
696    /// # }
697    /// ```
698    pub async fn wait_for_ready(&self) -> Result<(), TemplateError> {
699        self.template.wait_for_ready().await
700    }
701
702    /// Get the host port mapped to a container port.
703    ///
704    /// This is useful when using dynamic port allocation - Docker assigns
705    /// a random available host port which you can query with this method.
706    ///
707    /// # Errors
708    ///
709    /// Returns an error if the Docker command fails or no port mapping is found.
710    ///
711    /// # Example
712    ///
713    /// ```rust,no_run
714    /// # use docker_wrapper::testing::ContainerGuard;
715    /// # use docker_wrapper::RedisTemplate;
716    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
717    /// let guard = ContainerGuard::new(RedisTemplate::new("test"))
718    ///     .start()
719    ///     .await?;
720    ///
721    /// let host_port = guard.host_port(6379).await?;
722    /// println!("Redis available at localhost:{}", host_port);
723    /// # Ok(())
724    /// # }
725    /// ```
726    pub async fn host_port(&self, container_port: u16) -> Result<u16, TemplateError> {
727        let container_name = self.template.config().name.clone();
728        let result = PortCommand::new(&container_name)
729            .port(container_port)
730            .run()
731            .await
732            .map_err(TemplateError::DockerError)?;
733
734        // Return the first matching port mapping
735        if let Some(mapping) = result.port_mappings.first() {
736            return Ok(mapping.host_port);
737        }
738
739        Err(TemplateError::InvalidConfig(format!(
740            "No host port mapping found for container port {container_port}"
741        )))
742    }
743
744    /// Get the container logs.
745    ///
746    /// # Errors
747    ///
748    /// Returns an error if the Docker command fails.
749    pub async fn logs(&self) -> Result<String, TemplateError> {
750        let container_name = self.template.config().name.clone();
751        let result = LogsCommand::new(&container_name)
752            .execute()
753            .await
754            .map_err(TemplateError::DockerError)?;
755
756        Ok(format!("{}{}", result.stdout, result.stderr))
757    }
758
759    /// Manually stop the container.
760    ///
761    /// The container will still be removed on drop if `remove_on_drop` is enabled.
762    ///
763    /// # Errors
764    ///
765    /// Returns an error if the Docker command fails.
766    pub async fn stop(&self) -> Result<(), TemplateError> {
767        self.template.stop().await
768    }
769
770    /// Manually clean up the container (stop and remove).
771    ///
772    /// After calling this, the drop implementation will not attempt cleanup again.
773    ///
774    /// # Errors
775    ///
776    /// Returns an error if the Docker commands fail.
777    pub async fn cleanup(&self) -> Result<(), TemplateError> {
778        if self.cleaned_up.swap(true, Ordering::SeqCst) {
779            return Ok(()); // Already cleaned up
780        }
781
782        if self.options.stop_on_drop {
783            let _ = self.template.stop().await;
784        }
785        if self.options.remove_on_drop {
786            let _ = self.template.remove().await;
787        }
788        Ok(())
789    }
790}
791
792impl<T: Template + HasConnectionString> ContainerGuard<T> {
793    /// Get the connection string for the underlying service.
794    ///
795    /// This is a convenience method that delegates to the template's
796    /// `connection_string()` implementation. The format depends on the
797    /// service type (e.g., `redis://host:port` for Redis).
798    ///
799    /// This method is only available for templates that implement
800    /// [`HasConnectionString`].
801    ///
802    /// # Example
803    ///
804    /// ```rust,no_run
805    /// # use docker_wrapper::testing::ContainerGuard;
806    /// # use docker_wrapper::RedisTemplate;
807    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
808    /// let guard = ContainerGuard::new(RedisTemplate::new("redis").port(6379))
809    ///     .start()
810    ///     .await?;
811    ///
812    /// // Direct access to connection string
813    /// let conn = guard.connection_string();
814    /// // Instead of: guard.template().connection_string()
815    /// # Ok(())
816    /// # }
817    /// ```
818    #[must_use]
819    pub fn connection_string(&self) -> String {
820        self.template.connection_string()
821    }
822}
823
824impl<T: Template> Drop for ContainerGuard<T> {
825    fn drop(&mut self) {
826        // Skip cleanup if already done
827        if self.cleaned_up.load(Ordering::SeqCst) {
828            return;
829        }
830
831        // Skip cleanup for reused containers if not configured to clean them
832        if self.was_reused && !self.options.remove_on_drop {
833            return;
834        }
835
836        // Check if we're panicking
837        let panicking = std::thread::panicking();
838
839        if panicking && self.options.keep_on_panic {
840            let name = &self.template.config().name;
841            eprintln!("[ContainerGuard] Test panicked, keeping container '{name}' for debugging");
842
843            if self.options.capture_logs {
844                // Try to get logs - spawn a thread to avoid runtime conflicts
845                let container_name = self.template.config().name.clone();
846                let _ = std::thread::spawn(move || {
847                    if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
848                        .enable_all()
849                        .build()
850                    {
851                        if let Ok(result) =
852                            rt.block_on(async { LogsCommand::new(&container_name).execute().await })
853                        {
854                            let logs = format!("{}{}", result.stdout, result.stderr);
855                            eprintln!("[ContainerGuard] Container logs for '{container_name}':");
856                            eprintln!("{logs}");
857                        }
858                    }
859                })
860                .join();
861            }
862            return;
863        }
864
865        // Mark as cleaned up
866        self.cleaned_up.store(true, Ordering::SeqCst);
867
868        // Perform cleanup - need to spawn a runtime since Drop isn't async
869        let should_stop = self.options.stop_on_drop;
870        let should_remove = self.options.remove_on_drop;
871        let should_remove_network = self.options.remove_network_on_drop && self.network_created;
872        let container_name = self.template.config().name.clone();
873        let network_name = self.options.network.clone();
874        let stop_timeout = self.options.stop_timeout;
875
876        if !should_stop && !should_remove && !should_remove_network {
877            return;
878        }
879
880        // Perform cleanup - try to use existing runtime if available,
881        // otherwise create a new one (for non-async contexts)
882        if tokio::runtime::Handle::try_current().is_ok() {
883            // We're in an async context - use spawn_blocking to avoid blocking the runtime
884            let container_name_clone = container_name.clone();
885            let network_name_clone = network_name.clone();
886            let _ = std::thread::spawn(move || {
887                let rt = tokio::runtime::Builder::new_current_thread()
888                    .enable_all()
889                    .build()
890                    .expect("Failed to create runtime for cleanup");
891                rt.block_on(async {
892                    if should_stop {
893                        let mut cmd = StopCommand::new(&container_name_clone);
894                        if let Some(timeout) = stop_timeout {
895                            cmd = cmd.timeout_duration(timeout);
896                        }
897                        let _ = cmd.execute().await;
898                    }
899                    if should_remove {
900                        let _ = RmCommand::new(&container_name_clone).force().run().await;
901                    }
902                    // Remove network after container (network must be empty)
903                    if should_remove_network {
904                        if let Some(ref network) = network_name_clone {
905                            let _ = NetworkRmCommand::new(network).execute().await;
906                        }
907                    }
908                });
909            })
910            .join();
911        } else {
912            // Not in an async context - create a new runtime
913            if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
914                .enable_all()
915                .build()
916            {
917                rt.block_on(async {
918                    if should_stop {
919                        let mut cmd = StopCommand::new(&container_name);
920                        if let Some(timeout) = stop_timeout {
921                            cmd = cmd.timeout_duration(timeout);
922                        }
923                        let _ = cmd.execute().await;
924                    }
925                    if should_remove {
926                        let _ = RmCommand::new(&container_name).force().run().await;
927                    }
928                    // Remove network after container (network must be empty)
929                    if should_remove_network {
930                        if let Some(ref network) = network_name {
931                            let _ = NetworkRmCommand::new(network).execute().await;
932                        }
933                    }
934                });
935            }
936        }
937    }
938}
939
940/// A type-erased container guard entry for use in `ContainerGuardSet`.
941///
942/// This allows storing guards with different template types in the same collection.
943#[allow(dead_code)]
944struct GuardEntry {
945    /// Container name for lookup
946    name: String,
947    /// Cleanup function to stop and remove the container
948    cleanup_fn: Box<dyn FnOnce() + Send>,
949}
950
951/// Options for `ContainerGuardSet`.
952#[derive(Debug, Clone, Default)]
953#[allow(clippy::struct_excessive_bools)]
954pub struct GuardSetOptions {
955    /// Shared network for all containers
956    pub network: Option<String>,
957    /// Create the network if it doesn't exist (default: true)
958    pub create_network: bool,
959    /// Remove the network on drop (default: true when network is set)
960    pub remove_network_on_drop: bool,
961    /// Keep containers running if test panics (default: false)
962    pub keep_on_panic: bool,
963    /// Wait for each container to be ready after starting (default: true)
964    pub wait_for_ready: bool,
965}
966
967impl GuardSetOptions {
968    fn new() -> Self {
969        Self {
970            network: None,
971            create_network: true,
972            remove_network_on_drop: true,
973            keep_on_panic: false,
974            wait_for_ready: true,
975        }
976    }
977}
978
979/// A pending template entry waiting to be started.
980struct PendingEntry<T: Template + 'static> {
981    template: T,
982}
983
984/// Type-erased pending entry trait.
985trait PendingEntryTrait: Send {
986    /// Get the container name
987    fn name(&self) -> String;
988    /// Start the container and return a cleanup function
989    fn start(
990        self: Box<Self>,
991        network: Option<String>,
992        wait_for_ready: bool,
993        keep_on_panic: bool,
994    ) -> std::pin::Pin<
995        Box<dyn std::future::Future<Output = Result<GuardEntry, TemplateError>> + Send>,
996    >;
997}
998
999impl<T: Template + 'static> PendingEntryTrait for PendingEntry<T> {
1000    fn name(&self) -> String {
1001        self.template.config().name.clone()
1002    }
1003
1004    fn start(
1005        self: Box<Self>,
1006        network: Option<String>,
1007        wait_for_ready: bool,
1008        keep_on_panic: bool,
1009    ) -> std::pin::Pin<
1010        Box<dyn std::future::Future<Output = Result<GuardEntry, TemplateError>> + Send>,
1011    > {
1012        Box::pin(async move {
1013            let mut template = self.template;
1014            let name = template.config().name.clone();
1015
1016            // Set network if provided
1017            if let Some(ref net) = network {
1018                template.config_mut().network = Some(net.clone());
1019            }
1020
1021            // Start the container
1022            template.start_and_wait().await?;
1023
1024            // Wait for ready if configured
1025            if wait_for_ready {
1026                template.wait_for_ready().await?;
1027            }
1028
1029            // Create cleanup function
1030            let cleanup_name = name.clone();
1031            let cleanup_fn: Box<dyn FnOnce() + Send> = Box::new(move || {
1032                // Check if panicking and should keep
1033                if std::thread::panicking() && keep_on_panic {
1034                    eprintln!(
1035                        "[ContainerGuardSet] Test panicked, keeping container '{cleanup_name}' for debugging"
1036                    );
1037                    return;
1038                }
1039
1040                // Perform cleanup in a new runtime
1041                let _ = std::thread::spawn(move || {
1042                    if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
1043                        .enable_all()
1044                        .build()
1045                    {
1046                        rt.block_on(async {
1047                            let _ = StopCommand::new(&cleanup_name).execute().await;
1048                            let _ = RmCommand::new(&cleanup_name).force().run().await;
1049                        });
1050                    }
1051                })
1052                .join();
1053            });
1054
1055            Ok(GuardEntry { name, cleanup_fn })
1056        })
1057    }
1058}
1059
1060/// Builder for creating a [`ContainerGuardSet`].
1061///
1062/// # Example
1063///
1064/// ```rust,no_run
1065/// use docker_wrapper::testing::ContainerGuardSet;
1066/// use docker_wrapper::RedisTemplate;
1067///
1068/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1069/// let guards = ContainerGuardSet::new()
1070///     .with_network("test-network")
1071///     .add(RedisTemplate::new("redis-1"))
1072///     .add(RedisTemplate::new("redis-2"))
1073///     .start_all()
1074///     .await?;
1075///
1076/// // Access by name
1077/// assert!(guards.contains("redis-1"));
1078/// # Ok(())
1079/// # }
1080/// ```
1081pub struct ContainerGuardSetBuilder {
1082    entries: Vec<Box<dyn PendingEntryTrait>>,
1083    options: GuardSetOptions,
1084}
1085
1086impl ContainerGuardSetBuilder {
1087    /// Create a new builder.
1088    #[must_use]
1089    pub fn new() -> Self {
1090        Self {
1091            entries: Vec::new(),
1092            options: GuardSetOptions::new(),
1093        }
1094    }
1095
1096    /// Add a template to the set.
1097    ///
1098    /// The container name from the template's config is used as the key for lookup.
1099    #[allow(clippy::should_implement_trait)]
1100    #[must_use]
1101    pub fn add<T: Template + 'static>(mut self, template: T) -> Self {
1102        self.entries.push(Box::new(PendingEntry { template }));
1103        self
1104    }
1105
1106    /// Set a shared network for all containers.
1107    ///
1108    /// The network will be created if it doesn't exist (unless `create_network(false)` is called).
1109    #[must_use]
1110    pub fn with_network(mut self, network: impl Into<String>) -> Self {
1111        self.options.network = Some(network.into());
1112        self
1113    }
1114
1115    /// Set whether to create the network if it doesn't exist (default: true).
1116    #[must_use]
1117    pub fn create_network(mut self, create: bool) -> Self {
1118        self.options.create_network = create;
1119        self
1120    }
1121
1122    /// Set whether to remove the network on drop (default: true when network is set).
1123    #[must_use]
1124    pub fn remove_network_on_drop(mut self, remove: bool) -> Self {
1125        self.options.remove_network_on_drop = remove;
1126        self
1127    }
1128
1129    /// Set whether to keep containers running if the test panics (default: false).
1130    #[must_use]
1131    pub fn keep_on_panic(mut self, keep: bool) -> Self {
1132        self.options.keep_on_panic = keep;
1133        self
1134    }
1135
1136    /// Set whether to wait for each container to be ready (default: true).
1137    #[must_use]
1138    pub fn wait_for_ready(mut self, wait: bool) -> Self {
1139        self.options.wait_for_ready = wait;
1140        self
1141    }
1142
1143    /// Start all containers and return a guard set.
1144    ///
1145    /// Containers are started sequentially in the order they were added.
1146    ///
1147    /// # Errors
1148    ///
1149    /// Returns an error if any container fails to start. Containers that were
1150    /// successfully started before the failure will be cleaned up.
1151    pub async fn start_all(self) -> Result<ContainerGuardSet, TemplateError> {
1152        let mut network_created = false;
1153
1154        // Create network if needed
1155        if let Some(ref network) = self.options.network {
1156            if self.options.create_network {
1157                let result = NetworkCreateCommand::new(network)
1158                    .driver("bridge")
1159                    .execute()
1160                    .await;
1161                network_created = result.is_ok();
1162            }
1163        }
1164
1165        let mut guards: Vec<GuardEntry> = Vec::new();
1166        let mut names: HashMap<String, usize> = HashMap::new();
1167
1168        // Start each container
1169        for entry in self.entries {
1170            let name = entry.name();
1171            match entry
1172                .start(
1173                    self.options.network.clone(),
1174                    self.options.wait_for_ready,
1175                    self.options.keep_on_panic,
1176                )
1177                .await
1178            {
1179                Ok(guard) => {
1180                    names.insert(name, guards.len());
1181                    guards.push(guard);
1182                }
1183                Err(e) => {
1184                    // Clean up already-started containers on failure
1185                    for guard in guards {
1186                        (guard.cleanup_fn)();
1187                    }
1188                    // Clean up network if we created it
1189                    if network_created {
1190                        if let Some(ref network) = self.options.network {
1191                            let net = network.clone();
1192                            let _ = std::thread::spawn(move || {
1193                                if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
1194                                    .enable_all()
1195                                    .build()
1196                                {
1197                                    rt.block_on(async {
1198                                        let _ = NetworkRmCommand::new(&net).execute().await;
1199                                    });
1200                                }
1201                            })
1202                            .join();
1203                        }
1204                    }
1205                    return Err(e);
1206                }
1207            }
1208        }
1209
1210        Ok(ContainerGuardSet {
1211            guards,
1212            names,
1213            options: self.options,
1214            network_created,
1215        })
1216    }
1217}
1218
1219impl Default for ContainerGuardSetBuilder {
1220    fn default() -> Self {
1221        Self::new()
1222    }
1223}
1224
1225/// Manages multiple containers as a group with coordinated lifecycle.
1226///
1227/// All containers are cleaned up when the set is dropped. This is useful for
1228/// integration tests that require multiple services.
1229///
1230/// # Example
1231///
1232/// ```rust,no_run
1233/// use docker_wrapper::testing::ContainerGuardSet;
1234/// use docker_wrapper::RedisTemplate;
1235///
1236/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1237/// let guards = ContainerGuardSet::new()
1238///     .with_network("test-network")
1239///     .add(RedisTemplate::new("redis"))
1240///     .keep_on_panic(true)
1241///     .start_all()
1242///     .await?;
1243///
1244/// // Check if container exists
1245/// assert!(guards.contains("redis"));
1246///
1247/// // Get container names
1248/// for name in guards.names() {
1249///     println!("Container: {}", name);
1250/// }
1251/// # Ok(())
1252/// # }
1253/// ```
1254pub struct ContainerGuardSet {
1255    guards: Vec<GuardEntry>,
1256    names: HashMap<String, usize>,
1257    options: GuardSetOptions,
1258    network_created: bool,
1259}
1260
1261impl ContainerGuardSet {
1262    /// Create a new builder for a container guard set.
1263    #[allow(clippy::new_ret_no_self)]
1264    #[must_use]
1265    pub fn new() -> ContainerGuardSetBuilder {
1266        ContainerGuardSetBuilder::new()
1267    }
1268
1269    /// Check if a container with the given name exists in the set.
1270    #[must_use]
1271    pub fn contains(&self, name: &str) -> bool {
1272        self.names.contains_key(name)
1273    }
1274
1275    /// Get an iterator over container names in the set.
1276    pub fn names(&self) -> impl Iterator<Item = &str> {
1277        self.names.keys().map(String::as_str)
1278    }
1279
1280    /// Get the number of containers in the set.
1281    #[must_use]
1282    pub fn len(&self) -> usize {
1283        self.guards.len()
1284    }
1285
1286    /// Check if the set is empty.
1287    #[must_use]
1288    pub fn is_empty(&self) -> bool {
1289        self.guards.is_empty()
1290    }
1291
1292    /// Get the shared network name, if one was configured.
1293    #[must_use]
1294    pub fn network(&self) -> Option<&str> {
1295        self.options.network.as_deref()
1296    }
1297}
1298
1299impl Default for ContainerGuardSet {
1300    fn default() -> Self {
1301        Self {
1302            guards: Vec::new(),
1303            names: HashMap::new(),
1304            options: GuardSetOptions::new(),
1305            network_created: false,
1306        }
1307    }
1308}
1309
1310impl Drop for ContainerGuardSet {
1311    fn drop(&mut self) {
1312        // Clean up all containers
1313        for guard in self.guards.drain(..) {
1314            (guard.cleanup_fn)();
1315        }
1316
1317        // Clean up network if we created it
1318        if self.network_created && self.options.remove_network_on_drop {
1319            if let Some(ref network) = self.options.network {
1320                let net = network.clone();
1321                let _ = std::thread::spawn(move || {
1322                    if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
1323                        .enable_all()
1324                        .build()
1325                    {
1326                        rt.block_on(async {
1327                            let _ = NetworkRmCommand::new(&net).execute().await;
1328                        });
1329                    }
1330                })
1331                .join();
1332            }
1333        }
1334    }
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::*;
1340
1341    #[test]
1342    fn test_guard_options_default() {
1343        let opts = GuardOptions::default();
1344        assert!(opts.remove_on_drop);
1345        assert!(opts.stop_on_drop);
1346        assert!(!opts.keep_on_panic);
1347        assert!(!opts.capture_logs);
1348        assert!(!opts.reuse_if_running);
1349        assert!(!opts.wait_for_ready);
1350        assert!(opts.network.is_none());
1351        assert!(opts.create_network);
1352        assert!(!opts.remove_network_on_drop);
1353        assert!(opts.stop_timeout.is_none());
1354    }
1355
1356    #[test]
1357    fn test_builder_options() {
1358        // We can't easily test the builder without a real template,
1359        // but we can at least verify the module compiles
1360    }
1361}