devforge 0.3.0

Dev environment orchestrator — docker, health checks, mprocs, custom commands via TOML config
Documentation
use crate::config::{CustomCommand, DevConfig, Hook, RunnerConfig};
use crate::env::{fatal, log};
use std::path::Path;
use std::process::{self, Command};

/// Execute pre-launch hooks.
pub fn run_hooks(root: &Path, hooks: &[Hook]) {
    for hook in hooks {
        if let Some(condition) = &hook.condition {
            if let Some(missing) = &condition.missing {
                if root.join(missing).exists() {
                    continue;
                }
            }
        }

        let dir = match &hook.cwd {
            Some(cwd) => root.join(cwd),
            None => root.to_path_buf(),
        };

        log(&format!("Running hook: {}", hook.cmd));
        run_shell(&dir, &hook.cmd);
    }
}

/// Launch the configured process runner. Returns the exit status, or None if
/// the runner was interrupted (e.g. Ctrl+C with type = "none").
pub fn launch_runner(root: &Path, config: &DevConfig) -> Option<process::ExitStatus> {
    match &config.runner {
        Some(RunnerConfig::Mprocs) => Some(launch_mprocs(root, config)),
        Some(RunnerConfig::Shell { cmd }) => Some(launch_shell(root, cmd)),
        Some(RunnerConfig::None) => {
            block_until_ctrlc();
            None
        }
        // Backward compat: no runner block → infer mprocs
        None => Some(launch_mprocs(root, config)),
    }
}

/// Launch mprocs with the given config file. Returns the exit status.
pub fn launch_mprocs(root: &Path, config: &DevConfig) -> process::ExitStatus {
    log("Launching mprocs...");
    let config_path = root.join(&config.mprocs_config);
    match Command::new("mprocs")
        .arg("--config")
        .arg(&config_path)
        .current_dir(root)
        .status()
    {
        Ok(status) => status,
        Err(e) => fatal(&format!("Failed to run mprocs: {e}")),
    }
}

/// Run a custom command.
pub fn run_custom(root: &Path, command: &CustomCommand) {
    if command.cmd.is_empty() {
        fatal(&format!("Command '{}' has an empty cmd", command.name));
    }
    log(&format!("Running: {}", command.name));
    let status = Command::new(&command.cmd[0])
        .args(&command.cmd[1..])
        .current_dir(root)
        .status();
    match status {
        Ok(s) if s.success() => {}
        Ok(s) => process::exit(s.code().unwrap_or(1)),
        Err(e) => fatal(&format!("Failed to run {}: {e}", command.name)),
    }
}

fn launch_shell(root: &Path, cmd: &str) -> process::ExitStatus {
    log(&format!("Launching: {cmd}"));
    match Command::new("sh")
        .args(["-c", cmd])
        .current_dir(root)
        .status()
    {
        Ok(status) => status,
        Err(e) => fatal(&format!("Failed to run shell command: {e}")),
    }
}

fn block_until_ctrlc() {
    log("Running. Press Ctrl+C to stop.");
    let (tx, rx) = std::sync::mpsc::channel();
    ctrlc::set_handler(move || {
        let _ = tx.send(());
    })
    .expect("failed to set Ctrl+C handler");
    let _ = rx.recv();
}

fn run_shell(dir: &Path, cmd: &str) {
    let status = Command::new("sh")
        .args(["-c", cmd])
        .current_dir(dir)
        .status();
    match status {
        Ok(s) if s.success() => {}
        Ok(s) => fatal(&format!("Hook `{cmd}` exited with {s}")),
        Err(e) => fatal(&format!("Failed to run hook `{cmd}`: {e}")),
    }
}