devforge 0.3.0

Dev environment orchestrator — docker, health checks, mprocs, custom commands via TOML config
Documentation
use crate::config::{DockerConfig, HealthCheck};
use crate::env::{fatal, log};
use std::net::{TcpStream, ToSocketAddrs};
use std::path::Path;
use std::process::Command;
use std::thread;
use std::time::Duration;

/// Run `docker compose up -d` in the given directory.
pub fn compose_up(root: &Path, config: &DockerConfig) {
    log("Starting Docker services...");
    let status = Command::new("docker")
        .args(["compose", "-f", &config.compose_file, "up", "-d"])
        .current_dir(root)
        .status();
    match status {
        Ok(s) if s.success() => {}
        Ok(s) => fatal(&format!("docker compose up exited with {s}")),
        Err(e) => fatal(&format!("Failed to run docker compose: {e}")),
    }
}

/// Run `docker compose down` in the given directory. Best-effort (ignores errors).
pub fn compose_down(root: &Path, config: &DockerConfig) {
    log("Stopping Docker services...");
    let _ = Command::new("docker")
        .args(["compose", "-f", &config.compose_file, "down"])
        .current_dir(root)
        .status();
    log("Docker services stopped.");
}

/// Wait for all health checks to pass.
pub fn wait_for_health(root: &Path, checks: &[HealthCheck]) {
    for check in checks {
        log(&format!("Waiting for {}...", check.name));
        let last_err = wait_for(check.timeout, || run_check(root, check));
        if let Some(reason) = last_err {
            let desc = check_description(check);
            fatal(&format!(
                "\u{2717} health check failed: {} ({}) \u{2014} {}",
                check.name, desc, reason
            ));
        }
        log(&format!("{} ready.", check.name));
    }
}

fn check_description(check: &HealthCheck) -> String {
    if let Some(cmd) = &check.cmd {
        format!("cmd: {}", cmd.join(" "))
    } else if let Some(url) = &check.url {
        format!("url: {url}")
    } else if let Some(addr) = &check.tcp {
        format!("tcp: {addr}")
    } else {
        "none".to_string()
    }
}

fn run_check(root: &Path, check: &HealthCheck) -> Result<(), String> {
    if let Some(cmd) = &check.cmd {
        return run_cmd_check(root, cmd);
    }
    if let Some(url) = &check.url {
        return run_url_check(url);
    }
    if let Some(addr) = &check.tcp {
        return run_tcp_check(addr);
    }
    Ok(())
}

fn run_cmd_check(root: &Path, cmd: &[String]) -> Result<(), String> {
    if cmd.is_empty() {
        return Ok(());
    }
    match Command::new(&cmd[0])
        .args(&cmd[1..])
        .current_dir(root)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::piped())
        .output()
    {
        Ok(output) if output.status.success() => Ok(()),
        Ok(output) => {
            let code = output.status.code().unwrap_or(-1);
            let stderr = String::from_utf8_lossy(&output.stderr);
            let first_line = stderr.lines().next().unwrap_or("").trim();
            if first_line.is_empty() {
                Err(format!("exit code {code}"))
            } else {
                let msg: String = first_line.chars().take(200).collect();
                let msg = if first_line.len() > msg.len() {
                    format!("{msg}...")
                } else {
                    msg
                };
                Err(format!("exit code {code}: {msg}"))
            }
        }
        Err(e) => Err(format!("failed to execute: {e}")),
    }
}

fn run_url_check(url: &str) -> Result<(), String> {
    match ureq::get(url).call() {
        Ok(resp) => {
            if resp.status().is_success() {
                Ok(())
            } else {
                Err(format!("HTTP {}", resp.status()))
            }
        }
        Err(e) => Err(e.to_string()),
    }
}

fn run_tcp_check(addr: &str) -> Result<(), String> {
    // NOTE: to_socket_addrs uses synchronous OS DNS resolution, which can block
    // for 30+ seconds if DNS is unreachable. Prefer IP literals (e.g. 127.0.0.1:5432)
    // when Docker DNS may not be ready.
    let socket_addr = addr
        .to_socket_addrs()
        .map_err(|e| format!("invalid address: {e}"))?
        .next()
        .ok_or_else(|| "address resolved to nothing".to_string())?;
    TcpStream::connect_timeout(&socket_addr, Duration::from_secs(2))
        .map(|_| ())
        .map_err(|e| e.to_string())
}

fn wait_for(timeout_secs: u64, check: impl Fn() -> Result<(), String>) -> Option<String> {
    let mut last_err = None;
    for _ in 0..timeout_secs {
        match check() {
            Ok(()) => return None,
            Err(e) => last_err = Some(e),
        }
        thread::sleep(Duration::from_secs(1));
    }
    Some(last_err.unwrap_or_else(|| "timed out".to_string()))
}