executesoft 0.1.1

ExecuteSoft repository automation CLI
use crate::cli::{DevCommand, WatchArgs};
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(command: DevCommand) -> Result<()> {
    match command {
        DevCommand::Up(_) => docker_compose(&["up", "-d", "postgres", "redis", "nats"]),
        DevCommand::All(_) => docker_compose(&["up", "--build"]),
        DevCommand::Build(_) => docker_compose(&["build"]),
        DevCommand::Down(_) => docker_compose(&["down"]),
        DevCommand::Logs(_) => docker_compose(&["logs", "-f", "postgres", "redis", "nats"]),
        DevCommand::Reset(_) => docker_compose(&["down", "-v"]),
        DevCommand::Watch(args) => run_dev_watch(args),
    }
}

fn docker_compose(args: &[&str]) -> Result<()> {
    let file = env::var("DEV_COMPOSE_FILE")
        .unwrap_or_else(|_| "development/docker/compose.dev.yml".into());
    let mut cmd_args = vec!["compose".to_string(), "-f".to_string(), file];
    cmd_args.extend(args.iter().map(|arg| arg.to_string()));
    run_cmd(&repo_root(), "docker", &cmd_args)
}

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('=') {
            unsafe { env::set_var(key.trim(), 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(())
}