decision_cockpit 0.1.0

Layer — product decision memory with MCP tools and an embedded review dashboard
Documentation
//! Startup helpers: ensure Postgres is reachable before migrations run.

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

use crate::config::Config;
use crate::state::AppState;

const DB_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
const DB_QUICK_CHECK: Duration = Duration::from_secs(2);
const DB_POLL_INTERVAL: Duration = Duration::from_millis(500);
const POSTGRES_CONTAINER: &str = "decision_cockpit_pg";

/// Embedded at compile time — no `docker-compose.yml` path needed at runtime.
const EMBEDDED_COMPOSE: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docker-compose.yml"));

/// Start Postgres via Docker Compose (if enabled) and wait until the DB accepts connections.
pub async fn ensure_postgres(config: &Config) -> anyhow::Result<()> {
    // Fast path: Postgres already running (manual compose, prior session, etc.).
    if db_reachable_within(&config.database_url, DB_QUICK_CHECK).await {
        tracing::info!("database already reachable — skipping docker compose");
        return Ok(());
    }

    if config.auto_start_postgres {
        if let Err(e) = start_docker_compose() {
            tracing::warn!(error = %e, "docker compose failed — trying existing container");
            try_start_existing_container()?;
        }
    }

    wait_for_database(&config.database_url).await
}

fn start_docker_compose() -> anyhow::Result<()> {
    let workdir = compose_workdir()?;
    let compose_file = workdir.join("docker-compose.yml");
    std::fs::write(&compose_file, EMBEDDED_COMPOSE)?;

    let docker = find_docker()?;
    let compose_path = compose_file.to_str().unwrap();

    tracing::info!(
        workdir = %workdir.display(),
        "starting Postgres via embedded docker compose"
    );

    let status = Command::new(&docker)
        .args(["compose", "-f", compose_path, "up", "-d", "--wait"])
        .current_dir(&workdir)
        .status()
        .map_err(|e| docker_error(&docker, e))?;

    if status.success() {
        return Ok(());
    }

    // Older Docker installs use the standalone docker-compose binary.
    let legacy = find_docker_compose_legacy()?;
    tracing::info!("retrying with legacy docker-compose");
    let status = Command::new(&legacy)
        .args(["-f", compose_path, "up", "-d", "--wait"])
        .current_dir(&workdir)
        .status()
        .map_err(|e| {
            anyhow::anyhow!(
                "failed to run docker compose: {e}. Is Docker running? Start Docker Desktop, then re-enable the MCP server."
            )
        })?;

    if status.success() {
        Ok(())
    } else {
        anyhow::bail!(
            "docker compose failed (exit {:?})",
            status.code()
        )
    }
}

/// Start the named Postgres container if it already exists (e.g. created from the repo's compose).
fn try_start_existing_container() -> anyhow::Result<()> {
    let docker = find_docker()?;

    let inspect = Command::new(&docker)
        .args(["inspect", "-f", "{{.State.Running}}", POSTGRES_CONTAINER])
        .output();

    match inspect {
        Ok(out) if out.status.success() => {
            let running = String::from_utf8_lossy(&out.stdout).trim() == "true";
            if running {
                tracing::info!(container = POSTGRES_CONTAINER, "existing Postgres container already running");
                return Ok(());
            }
            tracing::info!(container = POSTGRES_CONTAINER, "starting existing Postgres container");
            let status = Command::new(&docker)
                .args(["start", POSTGRES_CONTAINER])
                .status()
                .map_err(|e| docker_error(&docker, e))?;
            if status.success() {
                Ok(())
            } else {
                anyhow::bail!("failed to start container {POSTGRES_CONTAINER}")
            }
        }
        _ => anyhow::bail!(
            "container {POSTGRES_CONTAINER} not found. Is Docker running? Start Docker Desktop, then re-enable the MCP server."
        ),
    }
}

/// Cache directory for the extracted compose file (volume data stays in Docker).
fn compose_workdir() -> anyhow::Result<PathBuf> {
    let dir = dirs::cache_dir()
        .or_else(dirs::data_local_dir)
        .unwrap_or_else(std::env::temp_dir)
        .join("decision-cockpit");
    std::fs::create_dir_all(&dir)?;
    Ok(dir)
}

async fn db_reachable_within(database_url: &str, timeout: Duration) -> bool {
    let deadline = tokio::time::Instant::now() + timeout;
    while tokio::time::Instant::now() < deadline {
        if AppState::connect(database_url).await.is_ok() {
            return true;
        }
        tokio::time::sleep(DB_POLL_INTERVAL).await;
    }
    false
}

async fn wait_for_database(database_url: &str) -> anyhow::Result<()> {
    let deadline = tokio::time::Instant::now() + DB_WAIT_TIMEOUT;

    while tokio::time::Instant::now() < deadline {
        match AppState::connect(database_url).await {
            Ok(state) => {
                drop(state);
                tracing::info!("database is ready");
                return Ok(());
            }
            Err(e) => {
                tracing::debug!(error = %e, "waiting for database");
                tokio::time::sleep(DB_POLL_INTERVAL).await;
            }
        }
    }

    anyhow::bail!(
        "database not reachable within {}s at {database_url}. \
         Is Docker running? Start Docker Desktop, then re-enable the MCP server.",
        DB_WAIT_TIMEOUT.as_secs()
    )
}

fn find_docker() -> anyhow::Result<String> {
    if command_exists("docker") {
        return Ok("docker".into());
    }
    for candidate in [
        "/usr/local/bin/docker",
        "/Applications/Docker.app/Contents/Resources/bin/docker",
    ] {
        if Path::new(candidate).exists() {
            return Ok(candidate.into());
        }
    }
    anyhow::bail!(
        "`docker` not found on PATH. Install Docker Desktop and ensure it is running."
    )
}

fn find_docker_compose_legacy() -> anyhow::Result<String> {
    if command_exists("docker-compose") {
        return Ok("docker-compose".into());
    }
    anyhow::bail!("`docker-compose` not found on PATH")
}

fn command_exists(cmd: &str) -> bool {
    Command::new("which")
        .arg(cmd)
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

fn docker_error(docker: &str, e: std::io::Error) -> anyhow::Error {
    anyhow::anyhow!(
        "failed to run `{docker}`: {e}. Is Docker running? Start Docker Desktop, then re-enable the MCP server."
    )
}

/// Open a URL in the system default browser.
pub fn open_browser(url: &str) -> anyhow::Result<()> {
    let status = if cfg!(target_os = "macos") {
        Command::new("open").arg(url).status()
    } else if cfg!(target_os = "windows") {
        Command::new("cmd").args(["/C", "start", "", url]).status()
    } else {
        Command::new("xdg-open").arg(url).status()
    }
    .map_err(|e| anyhow::anyhow!("failed to open browser for {url}: {e}"))?;

    if status.success() {
        Ok(())
    } else {
        anyhow::bail!(
            "failed to open browser (exit {:?}). Open {url} manually.",
            status.code()
        )
    }
}