executesoft 0.1.7

ExecuteSoft repository automation CLI
use crate::cli::WatchArgs;
use crate::dev::run_dev_watch;
use crate::service::{check_service, parse_simple_yaml};
use crate::util::{Result, make_vars, run_cmd, run_make, usage_error};
use std::collections::hash_map::DefaultHasher;
use std::env;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};

pub(crate) fn setup_current_service(args: &[String]) -> Result<()> {
    let root = current_service_root()?;
    apply_key_value_env(args);
    configure_service_dev_environment(&root);
    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()?;
    configure_service_dev_environment(&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,
    })
}

pub(crate) fn configure_current_or_compose_service_dev_environment() {
    if let Ok(root) = current_service_root() {
        configure_service_dev_environment(&root);
        return;
    }
    let Ok(compose_file) = env::var("DEV_COMPOSE_FILE") else {
        return;
    };
    let compose_path = PathBuf::from(compose_file);
    let compose_path = if compose_path.is_absolute() {
        compose_path
    } else {
        env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(compose_path)
    };
    if let Some(root) = compose_path.parent().and_then(Path::parent) {
        configure_service_dev_environment(root);
    }
}

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 configure_service_dev_environment(root: &Path) {
    let metadata = parse_simple_yaml(&root.join("service.yaml"));
    let service_key = format!(
        "{}-{}",
        metadata
            .get("domain")
            .map(String::as_str)
            .unwrap_or("service"),
        metadata
            .get("name")
            .cloned()
            .unwrap_or_else(|| fallback_service_name(root))
    );
    let slot = stable_port_slot(&service_key);
    let postgres_port = set_default_env("POSTGRES_PORT", 15432 + slot);
    let redis_port = set_default_env("REDIS_PORT", 16379 + slot);
    let nats_port = set_default_env("NATS_PORT", 14222 + slot);
    let mongodb_port = set_default_env("MONGODB_PORT", 17017 + slot);
    let _ = set_default_env("NATS_MONITOR_PORT", 18222 + slot);

    let env_file = root.join("configs/app.env.example");
    if let Ok(text) = fs::read_to_string(&env_file) {
        set_url_from_env_file("DATABASE_URL", &text, postgres_port);
        set_url_from_env_file("TENANT_CONTEXT_DATABASE_URL", &text, postgres_port);
        set_url_from_env_file("MONGODB_URI", &text, mongodb_port);
        set_url_from_env_file("CACHE_URL", &text, redis_port);
        set_url_from_env_file("TENANT_CONTEXT_REDIS_URL", &text, redis_port);
        set_url_from_env_file("NATS_URL", &text, nats_port);
    }
}

fn fallback_service_name(root: &Path) -> String {
    root.file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("local")
        .to_string()
}

pub(crate) fn stable_port_slot(service_key: &str) -> u16 {
    let mut hasher = DefaultHasher::new();
    service_key.hash(&mut hasher);
    (hasher.finish() % 1000) as u16
}

fn set_default_env(name: &str, port: u16) -> u16 {
    if let Ok(value) = env::var(name)
        && let Ok(parsed) = value.parse::<u16>()
    {
        return parsed;
    }
    unsafe { env::set_var(name, port.to_string()) };
    port
}

fn set_url_from_env_file(name: &str, text: &str, port: u16) {
    if env::var(name).is_ok() {
        return;
    }
    let Some(value) = env_file_value(name, text) else {
        return;
    };
    let Some(updated) = replace_localhost_port(&value, port) else {
        return;
    };
    unsafe { env::set_var(name, updated) };
}

fn env_file_value(name: &str, text: &str) -> Option<String> {
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let (key, value) = line.split_once('=')?;
        if key.trim() == name {
            return Some(value.trim().trim_matches(['"', '\'']).to_string());
        }
    }
    None
}

pub(crate) fn replace_localhost_port(value: &str, port: u16) -> Option<String> {
    for host in ["localhost:", "127.0.0.1:"] {
        if let Some(index) = value.find(host) {
            let port_start = index + host.len();
            let port_end = value[port_start..]
                .find(|c: char| !c.is_ascii_digit())
                .map(|offset| port_start + offset)
                .unwrap_or(value.len());
            if port_start == port_end {
                return None;
            }
            return Some(format!(
                "{}{}{}",
                &value[..port_start],
                port,
                &value[port_end..]
            ));
        }
    }
    None
}

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(());
    }
    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(),
        ],
    )
}

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)
}