devforge 0.3.0

Dev environment orchestrator — docker, health checks, mprocs, custom commands via TOML config
Documentation
mod config;
mod docker;
pub mod env;
mod runner;

pub use config::Config;

use std::fs;
use std::process;

/// Entry point for consumer xtask binaries. Reads `devforge.toml` from the
/// workspace root and dispatches the subcommand.
pub fn run() {
    let root = env::workspace_root();
    let toml_path = root.join("devforge.toml");
    let toml_str = fs::read_to_string(&toml_path).unwrap_or_else(|e| {
        env::fatal(&format!("Failed to read {}: {e}", toml_path.display()));
    });
    let config = Config::load(&toml_str).unwrap_or_else(|e| {
        env::fatal(&format!("Failed to parse devforge.toml: {e}"));
    });

    let args: Vec<String> = std::env::args().skip(1).collect();
    match args.first().map(|s| s.as_str()) {
        Some("dev") => cmd_dev(&root, &config),
        Some("infra") => cmd_infra(&root, &config),
        Some(name) => {
            if let Some(cmd) = config.commands.iter().find(|c| c.name == name) {
                cmd_custom(&root, &config, cmd);
            } else {
                eprintln!("Unknown command: {name}\n");
                print_usage(&config);
                process::exit(1);
            }
        }
        None => {
            print_usage(&config);
            process::exit(1);
        }
    }
}

fn preflight(root: &std::path::Path, config: &Config) {
    // Copy templates before checking existence
    for entry in &config.env_files {
        if let Some(template) = &entry.template {
            let target = root.join(&entry.path);
            if !target.exists() {
                let src = root.join(template);
                if !src.exists() {
                    env::fatal(&format!(
                        "Template not found: {} (for {})",
                        src.display(),
                        entry.path,
                    ));
                }
                fs::copy(&src, &target).unwrap_or_else(|e| {
                    env::fatal(&format!(
                        "Failed to copy {} \u{2192} {}: {e}",
                        src.display(),
                        target.display(),
                    ));
                });
                env::log(&format!(
                    "Copied {} \u{2192} {}",
                    template, entry.path,
                ));
            }
        }
    }

    for entry in &config.env_files {
        env::check_file(&root.join(&entry.path));
    }

    // Load .env files (files without extension or with .env extension)
    for entry in &config.env_files {
        let path = root.join(&entry.path);
        if path.exists() {
            let is_dotenv = path.extension().is_none()
                || path.extension().is_some_and(|e| e == "env");
            if is_dotenv {
                env::load_dotenv(&path);
            }
        }
    }

    for tool in &config.required_tools {
        env::require_cmd(tool);
    }
}

fn cmd_dev(root: &std::path::Path, config: &Config) {
    preflight(root, config);

    docker::compose_up(root, &config.docker);
    docker::wait_for_health(root, &config.docker.health_checks);
    runner::run_hooks(root, &config.dev.hooks);

    let status = runner::launch_runner(root, &config.dev);

    docker::compose_down(root, &config.docker);

    if let Some(status) = status {
        match status.code() {
            Some(0) | None => {}
            Some(code) => process::exit(code),
        }
    }
}

fn cmd_infra(root: &std::path::Path, config: &Config) {
    preflight(root, config);

    docker::compose_up(root, &config.docker);
    docker::wait_for_health(root, &config.docker.health_checks);

    env::log("Infrastructure 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();

    docker::compose_down(root, &config.docker);
}

fn cmd_custom(root: &std::path::Path, config: &Config, cmd: &config::CustomCommand) {
    preflight(root, config);

    if cmd.docker {
        docker::compose_up(root, &config.docker);
        docker::wait_for_health(root, &config.docker.health_checks);
    }

    runner::run_custom(root, cmd);

    if cmd.docker {
        docker::compose_down(root, &config.docker);
    }
}

fn print_usage(config: &Config) {
    eprintln!("Usage: cargo xtask <command>\n");
    eprintln!("Commands:");
    eprintln!("  dev     Start all services in mprocs TUI");
    eprintln!("  infra   Start Docker infrastructure only");
    for cmd in &config.commands {
        eprintln!("  {:<7} {}", cmd.name, cmd.description);
    }
}