executesoft 0.2.9

ExecuteSoft repository automation CLI
use crate::cli::{DevCommand, KeyValueArgs, WatchArgs};
use crate::service_local::dev_current_service;
use crate::util::{Result, repo_root, run_cmd, usage_error};
use std::collections::HashSet;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, UNIX_EPOCH};

pub(crate) fn run_dev(command: Option<DevCommand>) -> Result<()> {
    match command {
        Some(command) => run_dev_command(command),
        None => dev_current_service(),
    }
}

pub(crate) fn run_dev_command(command: DevCommand) -> Result<()> {
    match command {
        DevCommand::Up(args) => {
            apply_key_value_env(&args);
            let mut args = vec!["up".to_string(), "-d".to_string()];
            args.extend(dev_services());
            docker_compose_owned(args)?;
            ensure_shared_postgres_databases()
        }
        DevCommand::All(args) => {
            apply_key_value_env(&args);
            docker_compose(&["up", "--build"])
        }
        DevCommand::Build(args) => {
            apply_key_value_env(&args);
            docker_compose(&["build"])
        }
        DevCommand::Down(args) => {
            apply_key_value_env(&args);
            docker_compose(&["down"])
        }
        DevCommand::Logs(args) => {
            apply_key_value_env(&args);
            let mut args = vec!["logs".to_string(), "-f".to_string()];
            args.extend(dev_services());
            docker_compose_owned(args)
        }
        DevCommand::Reset(args) => {
            apply_key_value_env(&args);
            docker_compose(&["down", "-v"])
        }
        DevCommand::Watch(args) => run_dev_watch(args),
    }
}

fn apply_key_value_env(args: &KeyValueArgs) {
    for arg in &args.vars {
        if let Some((key, value)) = arg.split_once('=') {
            unsafe { env::set_var(key, value) };
        }
    }
}

fn docker_compose(args: &[&str]) -> Result<()> {
    docker_compose_owned(args.iter().map(|arg| arg.to_string()).collect())
}

fn docker_compose_owned(args: Vec<String>) -> Result<()> {
    let file =
        env::var("DEV_COMPOSE_FILE").unwrap_or_else(|_| "tools/local-dev/compose.yml".into());
    let file = resolve_compose_file(&file)?;
    if !file.exists() {
        return usage_error(format!(
            "DEV_COMPOSE_FILE does not exist: {}",
            file.display()
        ));
    }
    let mut cmd_args = vec![
        "compose".to_string(),
        "-f".to_string(),
        file.display().to_string(),
    ];
    cmd_args.extend(args);
    run_cmd(&repo_root(), "docker", &cmd_args)
}

fn ensure_shared_postgres_databases() -> Result<()> {
    if env::var("EXE_DEV_SKIP_DATABASE_BOOTSTRAP")
        .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
        .unwrap_or(false)
    {
        return Ok(());
    }
    let databases = shared_postgres_databases()?.join(" ");
    if databases.is_empty() {
        return Ok(());
    }
    let script = format!(
        r#"set -eu
until pg_isready -U executesoft -d postgres >/dev/null 2>&1; do
  sleep 1
done
for db in {databases}; do
  if ! psql -U executesoft -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'" | grep -q 1; then
    createdb -U executesoft "$db"
  fi
done"#,
    );
    run_cmd(
        &repo_root(),
        "docker",
        &[
            "compose".into(),
            "-f".into(),
            "tools/local-dev/compose.yml".into(),
            "exec".into(),
            "-T".into(),
            "postgres".into(),
            "sh".into(),
            "-lc".into(),
            script,
        ],
    )
}

pub(crate) fn shared_postgres_databases() -> Result<Vec<String>> {
    let mut databases = Vec::new();
    if let Ok(extra) = env::var("EXE_DEV_DATABASES") {
        databases.extend(split_database_names(&extra));
    }
    collect_service_databases(&repo_root().join("services"), &mut databases)?;
    databases.sort();
    databases.dedup();
    Ok(databases)
}

pub(crate) fn collect_service_databases(root: &Path, databases: &mut Vec<String>) -> Result<()> {
    if !root.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            collect_service_databases(&path, databases)?;
            continue;
        }
        if path.file_name().and_then(OsStr::to_str) == Some("app.env.example") {
            collect_databases_from_env_file(&path, databases)?;
        } else if path.file_name().and_then(OsStr::to_str) == Some("Makefile") {
            collect_databases_from_makefile(&path, databases)?;
        }
    }
    Ok(())
}

fn collect_databases_from_env_file(path: &Path, databases: &mut Vec<String>) -> Result<()> {
    let text = fs::read_to_string(path)?;
    for line in text.lines() {
        let Some((key, value)) = line.trim().split_once('=') else {
            continue;
        };
        if matches!(key.trim(), "DATABASE_URL" | "TENANT_CONTEXT_DATABASE_URL")
            && let Some(database) = postgres_database_name(value.trim())
        {
            databases.push(database);
        }
    }
    Ok(())
}

fn collect_databases_from_makefile(path: &Path, databases: &mut Vec<String>) -> Result<()> {
    let text = fs::read_to_string(path)?;
    for line in text.lines() {
        let Some((key, value)) = line.trim().split_once("?=") else {
            continue;
        };
        if key.trim() == "MIGRATION_DB_NAME" {
            databases.extend(split_database_names(value));
        }
    }
    Ok(())
}

pub(crate) fn postgres_database_name(url: &str) -> Option<String> {
    let without_query = url.split_once('?').map(|(value, _)| value).unwrap_or(url);
    let database = without_query.rsplit('/').next()?.trim();
    if database.is_empty() || database.contains('$') || database.contains("__") {
        return None;
    }
    Some(database.to_string())
}

fn split_database_names(value: &str) -> Vec<String> {
    value
        .split([',', ' ', '\t'])
        .map(|item| item.trim().trim_matches(['"', '\'']))
        .filter(|item| !item.is_empty() && !item.contains('$') && !item.contains("__"))
        .map(String::from)
        .collect()
}

fn dev_services() -> Vec<String> {
    if let Ok(services) = env::var("DEV_SERVICES") {
        return split_service_names(&services);
    }

    let file =
        env::var("DEV_COMPOSE_FILE").unwrap_or_else(|_| "tools/local-dev/compose.yml".into());
    resolve_compose_file(&file)
        .ok()
        .and_then(|path| shared_dependency_services_from_compose(&path).ok())
        .filter(|services| !services.is_empty())
        .unwrap_or_else(|| vec!["postgres".into(), "redis".into(), "nats".into()])
}

fn resolve_compose_file(file: &str) -> Result<std::path::PathBuf> {
    let raw = Path::new(file);
    if raw.is_absolute() {
        return Ok(raw.to_path_buf());
    }
    let cwd_path = env::current_dir()?.join(raw);
    if cwd_path.exists() {
        return Ok(cwd_path);
    }
    Ok(repo_root().join(raw))
}

fn split_service_names(value: &str) -> Vec<String> {
    value
        .split([',', ' ', '\t', '\n'])
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .map(String::from)
        .collect()
}

pub(crate) fn shared_dependency_services_from_compose(path: &Path) -> Result<Vec<String>> {
    let text = fs::read_to_string(path)?;
    Ok(parse_compose_services(&text))
}

pub(crate) fn parse_compose_services(text: &str) -> Vec<String> {
    let mut in_services = false;
    let mut services = Vec::new();

    for raw_line in text.lines() {
        let line = raw_line.split('#').next().unwrap_or_default();
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        if !raw_line.starts_with(' ') && !raw_line.starts_with('\t') {
            in_services = trimmed == "services:";
            continue;
        }

        if !in_services {
            continue;
        }

        let leading_spaces = raw_line.chars().take_while(|value| *value == ' ').count();
        if leading_spaces != 2 || !trimmed.ends_with(':') {
            continue;
        }

        let service = trimmed.trim_end_matches(':').trim();
        if !service.is_empty() {
            services.push(service.to_string());
        }
    }

    services
}

pub(crate) fn run_dev_watch(args: WatchArgs) -> Result<()> {
    let mut ignored: HashSet<String> = [
        "response-mappers.json",
        "routes.yaml",
        "public-routes.json",
        "route-permissions.json",
        "openapi.yaml",
    ]
    .into_iter()
    .map(String::from)
    .collect();
    for item in args.ignore {
        ignored.insert(item);
    }
    if args.command.is_empty() {
        return usage_error("dev command is required".into());
    }
    load_env_file(Path::new(&args.env_file));
    let mut previous = snapshot(Path::new(&args.watch_root), &ignored)?;
    let mut child = start_child(&args.command)?;
    let poll = Duration::from_millis((args.poll * 1000.0) as u64);
    loop {
        thread::sleep(poll);
        let current = snapshot(Path::new(&args.watch_root), &ignored)?;
        if current != previous {
            println!("[dev] change detected, restarting");
            stop_child(&mut child);
            child = start_child(&args.command)?;
            previous = current;
        }
    }
}

pub(crate) fn load_env_file(path: &Path) {
    let Ok(text) = fs::read_to_string(path) else {
        return;
    };
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some((key, value)) = line.split_once('=') {
            let key = key.trim();
            let value = value.trim().trim_matches(['"', '\'']);
            if key.is_empty() || value.is_empty() {
                continue;
            }
            if env::var(key).is_err() {
                unsafe { env::set_var(key, value) };
            }
        }
    }
}

fn start_child(command: &[String]) -> Result<Child> {
    println!("[dev] starting: {}", command.join(" "));
    Ok(Command::new(&command[0])
        .args(&command[1..])
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .spawn()?)
}

fn stop_child(child: &mut Child) {
    let _ = child.kill();
    let _ = child.wait();
}

fn snapshot(root: &Path, ignored: &HashSet<String>) -> Result<String> {
    let mut rows = Vec::new();
    snapshot_walk(root, ignored, &mut rows)?;
    rows.sort();
    Ok(rows.join("\n"))
}

fn snapshot_walk(root: &Path, ignored: &HashSet<String>, rows: &mut Vec<String>) -> Result<()> {
    if !root.exists() {
        return Ok(());
    }
    let pruned: HashSet<&str> = [
        ".git",
        ".pytest_cache",
        "__pycache__",
        "build",
        "coverage",
        "dist",
        "node_modules",
        "target",
    ]
    .into_iter()
    .collect();
    let allowed: HashSet<&str> = [
        ".go", ".mod", ".sum", ".ts", ".js", ".json", ".py", ".toml", ".rs", ".yaml", ".yml",
        ".proto",
    ]
    .into_iter()
    .collect();
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        let path = entry.path();
        let name = entry.file_name().to_string_lossy().to_string();
        if path.is_dir() {
            if !pruned.contains(name.as_str()) {
                snapshot_walk(&path, ignored, rows)?;
            }
        } else if !ignored.contains(&name)
            && path
                .extension()
                .and_then(OsStr::to_str)
                .is_some_and(|ext| allowed.contains(format!(".{ext}").as_str()))
        {
            let modified = entry
                .metadata()?
                .modified()?
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_nanos();
            rows.push(format!("{modified} {}", path.display()));
        }
    }
    Ok(())
}