executesoft 0.2.5

ExecuteSoft repository automation CLI
use crate::cli::WatchArgs;
use crate::dev::{run_dev_command, run_dev_watch};
use crate::service::{check_service, parse_simple_yaml};
use crate::util::{Result, make_vars, repo_root, run_cmd, run_make, usage_error};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

pub(crate) fn setup_current_service(args: &[String]) -> Result<()> {
    let root = current_service_root()?;
    apply_key_value_env(args);
    println!("[setup] service: {}", root.display());
    check_service(&root)?;
    run_generators(&root)?;
    install_language_dependencies(&root)?;
    start_service_dependencies(&root)?;
    println!("[setup] ready. Run `exe dev` from {}", root.display());
    Ok(())
}

pub(crate) fn dev_current_service() -> Result<()> {
    let root = current_service_root()?;
    println!("[dev] service: {}", root.display());
    run_generators(&root)?;
    start_service_dependencies(&root)?;
    let command = service_dev_command(&root)?;
    run_dev_watch(WatchArgs {
        env_file: root.join("configs/app.env.example").display().to_string(),
        watch_root: root.display().to_string(),
        ignore: vec![],
        poll: 1.0,
        command,
    })
}

fn current_service_root() -> Result<PathBuf> {
    let cwd = env::current_dir()?;
    find_service_root_from(&cwd).ok_or_else(|| {
        "service.yaml not found. Run this command from a service checkout or service subdirectory."
            .into()
    })
}

pub(crate) fn find_service_root_from(start: &Path) -> Option<PathBuf> {
    let mut dir = start.to_path_buf();
    loop {
        if dir.join("service.yaml").is_file() {
            return Some(dir);
        }
        if !dir.pop() {
            return None;
        }
    }
}

fn apply_key_value_env(args: &[String]) {
    for item in make_vars(args) {
        if let Some((key, value)) = item.split_once('=') {
            unsafe { env::set_var(key, value) };
        }
    }
}

fn run_generators(root: &Path) -> Result<()> {
    if truthy_env("SKIP_GENERATE") || truthy_env("EXE_SETUP_SKIP_GENERATE") {
        println!("[setup] skipping generators");
        return Ok(());
    }
    if root.join("Makefile").is_file() {
        println!("[setup] running make generate");
        return run_make(root, "generate", &[]);
    }
    let candidates = [
        "tools/generate.sh",
        "tools/generate-auth-artifacts.sh",
        "tools/generate-gateway-artifacts.sh",
    ];
    for candidate in candidates {
        let path = root.join(candidate);
        if path.is_file() {
            println!("[setup] running {candidate}");
            return run_cmd(root, "sh", &[candidate.to_string()]);
        }
    }
    println!("[setup] no local generator found");
    Ok(())
}

fn install_language_dependencies(root: &Path) -> Result<()> {
    if truthy_env("SKIP_DEPS") || truthy_env("EXE_SETUP_SKIP_DEPS") {
        println!("[setup] skipping language dependencies");
        return Ok(());
    }
    if root.join("go.mod").is_file() {
        println!("[setup] downloading Go modules");
        run_cmd(root, "go", &["mod".into(), "download".into()])?;
    }
    if root.join("Cargo.toml").is_file() {
        println!("[setup] fetching Rust crates");
        run_cmd(root, "cargo", &["fetch".into()])?;
    }
    if root.join("package.json").is_file() {
        if root.join("bun.lock").is_file() || root.join("bun.lockb").is_file() {
            println!("[setup] installing Bun packages");
            run_cmd(root, "bun", &["install".into()])?;
        } else if root.join("package-lock.json").is_file() {
            println!("[setup] installing npm packages");
            run_cmd(root, "npm", &["ci".into()])?;
        } else {
            println!("[setup] package.json found; skipping install because no lockfile exists");
        }
    }
    Ok(())
}

fn start_service_dependencies(root: &Path) -> Result<()> {
    if truthy_env("SKIP_DOCKER") || truthy_env("EXE_SETUP_SKIP_DOCKER") {
        println!("[setup] skipping Docker dependencies");
        return Ok(());
    }
    if let Some(shared_compose) = shared_compose_file() {
        println!("[setup] starting shared local dependencies");
        unsafe { env::set_var("DEV_COMPOSE_FILE", shared_compose.display().to_string()) };
        return run_dev_command(crate::cli::DevCommand::Up(crate::cli::KeyValueArgs {
            vars: vec![],
        }));
    }

    let compose = root.join("development/compose.dev.yml");
    if !compose.is_file() {
        println!("[setup] no development/compose.dev.yml found");
        return Ok(());
    }
    println!("[setup] starting service dependencies");
    run_cmd(
        root,
        "docker",
        &[
            "compose".into(),
            "-f".into(),
            compose.display().to_string(),
            "up".into(),
            "-d".into(),
        ],
    )
}

fn shared_compose_file() -> Option<PathBuf> {
    let path = repo_root().join("tools/local-dev/compose.yml");
    path.is_file().then_some(path)
}

pub(crate) fn service_dev_command(root: &Path) -> Result<Vec<String>> {
    let metadata = parse_simple_yaml(&root.join("service.yaml"));
    let runtime = metadata.get("runtime").map(String::as_str).unwrap_or("");
    let command = match runtime {
        "go" if root.join("cmd/server/main.go").is_file() => vec!["go", "run", "./cmd/server"],
        "go" => vec!["go", "run", "./cmd/server"],
        "rust" => vec!["cargo", "run"],
        "typescript" if has_script(root, "\"dev\"") => {
            if root.join("bun.lock").is_file() || root.join("bun.lockb").is_file() {
                vec!["bun", "run", "dev"]
            } else {
                vec!["npm", "run", "dev"]
            }
        }
        "typescript" => vec!["node", "src/server.js"],
        "python" => vec!["python", "-m", "src.server"],
        _ => {
            return usage_error(format!(
                "unsupported service runtime `{runtime}` in {}",
                root.join("service.yaml").display()
            ));
        }
    };
    Ok(command.into_iter().map(String::from).collect())
}

fn has_script(root: &Path, script_name: &str) -> bool {
    fs::read_to_string(root.join("package.json"))
        .map(|text| text.contains(script_name))
        .unwrap_or(false)
}

fn truthy_env(name: &str) -> bool {
    env::var(name)
        .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
        .unwrap_or(false)
}