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}