vibe-tests 0.0.1

Integration test framework for MCP servers with LLM-powered tool calling.
Documentation
//! Docker Compose wrapper.
//! Manages multi-container test infrastructure.

use std::{
    path::PathBuf,
    process::{Child, Command},
    time::Duration,
};

use crate::base::{
    error::{TestError, TestsResult},
    tee_writer::TeeWriter,
};

/// Docker Compose instance.
/// Logs all output through TeeWriter to file and terminal.
pub struct Compose {
    /// Path to docker-compose file.
    file: String,
    /// Tee writer for stdout/stderr redirection.
    tee: TeeWriter,
    /// Timeout for health check polling.
    timeout: Duration,
    /// Docker compose process handle (killed on drop).
    child: Option<Child>,
}

impl Compose {
    /// Create new compose instance for the given file.
    pub fn new(file: impl Into<PathBuf>, tee: TeeWriter, timeout: Duration) -> Self {
        let file: PathBuf = file.into();
        Self {
            file: file.to_string_lossy().into_owned(),
            tee,
            timeout,
            child: None,
        }
    }

    /// Start compose in detached mode.
    /// All output goes to log file via TeeWriter.
    pub async fn up(self) -> TestsResult<Self> {
        tracing::info!("Starting docker compose: {}", self.file);
        let mut child = Command::new("docker")
            .args(["compose", "-f", &self.file, "up", "-d"])
            .stdout(self.tee.clone())
            .stderr(self.tee.clone())
            .spawn()
            .map_err(TestError::Io)?;
        let status = child.wait().map_err(TestError::Io)?;
        if !status.success() {
            return Err(TestError::Setup(format!(
                "Failed to start docker compose: {}",
                self.file
            )));
        }
        // Wait for all services to be healthy
        self.wait_healthy().await?;
        Ok(self)
    }

    /// Poll compose services until all are running.
    async fn wait_healthy(&self) -> TestsResult<()> {
        let start = std::time::Instant::now();
        loop {
            if start.elapsed() > self.timeout {
                return Err(TestError::Timeout("Compose services not healthy".into()));
            }
            let output = Command::new("docker")
                .args(["compose", "-f", &self.file, "ps", "--format", "json"])
                .output()
                .map_err(TestError::Io)?;
            if output.status.success() {
                let stdout = String::from_utf8_lossy(&output.stdout);
                let all_healthy = stdout
                    .lines()
                    .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
                    .all(|s| {
                        let state = s["State"].as_str().unwrap_or("");
                        let health = s["Health"].as_str().unwrap_or("");
                        state == "running" && (health.is_empty() || health == "healthy")
                    });
                if all_healthy && !stdout.is_empty() {
                    return Ok(());
                }
            }
            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
        }
    }

    /// Stop compose and remove volumes.
    pub fn down(&mut self) {
        let _ = Command::new("docker")
            .args(["compose", "-f", &self.file, "down", "-v"])
            .stdout(self.tee.clone())
            .stderr(self.tee.clone())
            .status();
        if let Some(mut child) = self.child.take() {
            let _ = child.kill();
            let _ = child.wait();
        }
    }
}