executesoft 0.1.6

ExecuteSoft repository automation CLI
use crate::cli::{DevCommand, KeyValueArgs, WatchArgs};
use crate::service_local::{
    configure_current_or_compose_service_dev_environment, 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(),
    }
}

fn run_dev_command(command: DevCommand) -> Result<()> {
    match command {
        DevCommand::Up(args) => {
            apply_key_value_env(&args);
            configure_current_or_compose_service_dev_environment();
            let mut args = vec!["up".to_string(), "-d".to_string()];
            args.extend(dev_services());
            docker_compose_owned(args)
        }
        DevCommand::All(args) => {
            apply_key_value_env(&args);
            configure_current_or_compose_service_dev_environment();
            docker_compose(&["up", "--build"])
        }
        DevCommand::Build(args) => {
            apply_key_value_env(&args);
            configure_current_or_compose_service_dev_environment();
            docker_compose(&["build"])
        }
        DevCommand::Down(args) => {
            apply_key_value_env(&args);
            configure_current_or_compose_service_dev_environment();
            docker_compose(&["down"])
        }
        DevCommand::Logs(args) => {
            apply_key_value_env(&args);
            configure_current_or_compose_service_dev_environment();
            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);
            configure_current_or_compose_service_dev_environment();
            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").map_err(|_| {
        "DEV_COMPOSE_FILE is required for compose-based dev commands. Use service-local `exe dev` inside a service checkout, or pass DEV_COMPOSE_FILE=path/to/compose.yml."
    })?;
    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 dev_services() -> Vec<String> {
    env::var("DEV_SERVICES")
        .unwrap_or_else(|_| "postgres redis nats".into())
        .split([',', ' '])
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .map(String::from)
        .collect()
}

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

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

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();
            if env::var(key).is_err() {
                unsafe { env::set_var(key, value.trim().trim_matches(['"', '\''])) };
            }
        }
    }
}

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