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";
const EMBEDDED_COMPOSE: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docker-compose.yml"));
pub async fn ensure_postgres(config: &Config) -> anyhow::Result<()> {
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(());
}
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()
)
}
}
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."
),
}
}
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."
)
}
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()
)
}
}