executesoft 0.2.1

ExecuteSoft repository automation CLI
use crate::cli::{Cli, Commands, DeployCommand};
use crate::db::run_db_command;
use crate::dev::run_dev;
use crate::gateway::{run_gateway_command, sync_service_gateway_route_imports};
use crate::service::run_service_command;
use crate::service_local::setup_current_service;
use crate::util::{Result, make_vars, repo_root, run_cmd, run_make};
use serde_json::Value;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::process::Command;

pub(crate) fn run_cli(cli: Cli) -> Result<()> {
    match cli.command {
        Commands::Service { command } => run_service_command(command),
        Commands::Gateway { command } => run_gateway_command(command),
        Commands::Db { command } => run_db_command(command),
        Commands::Setup(args) => setup_current_service(&args.vars),
        Commands::Dev { command } => run_dev(command),
        Commands::Sync => run_sync(),
        Commands::Release(args) => run_release(&args.vars),
        Commands::Publish(args) => run_publish(&args.vars),
        Commands::ReleaseSync(args) => run_release_sync(&args.vars),
        Commands::Deploy { command } => run_deploy_command(command),
    }
}

fn run_deploy_command(command: DeployCommand) -> Result<()> {
    match command {
        DeployCommand::Kubernetes(args) => {
            run_sync()?;
            run_make(
                &repo_root().join("devops"),
                "deploy-kubernetes",
                &make_vars(&args.vars),
            )
        }
        DeployCommand::Docker(args) => run_make(
            &repo_root().join("devops"),
            "deploy-docker",
            &make_vars(&args.vars),
        ),
        DeployCommand::Migrations(args) => run_make(
            &repo_root().join("devops"),
            "apply-migrations",
            &make_vars(&args.vars),
        ),
        DeployCommand::ObservabilityUp(args) => run_make(
            &repo_root().join("devops"),
            "observability-full-up",
            &make_vars(&args.vars),
        ),
        DeployCommand::ObservabilityDown(args) => run_make(
            &repo_root().join("devops"),
            "observability-full-down",
            &make_vars(&args.vars),
        ),
        DeployCommand::BackupNow(args) => run_make(
            &repo_root().join("devops"),
            "backup-now",
            &make_vars(&args.vars),
        ),
        DeployCommand::InstallBackupCron(args) => run_make(
            &repo_root().join("devops"),
            "install-backup-cron",
            &make_vars(&args.vars),
        ),
        DeployCommand::ReleaseSync(args) => run_release_sync(&args.vars),
        DeployCommand::ReloadCaddy(args) => run_make(
            &repo_root().join("devops"),
            "reload-caddy",
            &make_vars(&args.vars),
        ),
        DeployCommand::Restart(args) => run_make(
            &repo_root().join("devops"),
            "restart",
            &make_vars(&args.vars),
        ),
        DeployCommand::Recreate(args) => run_make(
            &repo_root().join("devops"),
            "recreate",
            &make_vars(&args.vars),
        ),
    }
}

fn run_sync() -> Result<()> {
    sync_removed_service_artifacts()?;
    run_make(&repo_root().join("services/core/gateway"), "generate", &[])?;
    run_docs_api_sync()?;
    run_cmd(
        &repo_root(),
        "devops/droplet/scripts/sync-service-assets.sh",
        &[],
    )
}

fn sync_removed_service_artifacts() -> Result<()> {
    sync_service_gateway_route_imports()?;
    prune_stale_gateway_route_imports()?;
    prune_orphan_shared_protobufs()
}

pub(crate) fn prune_stale_gateway_route_imports() -> Result<()> {
    let root = repo_root();
    let api_dir = root.join("services/core/gateway/api");
    let gateway_routes = api_dir.join("gateway-routes.json");
    let text = fs::read_to_string(&gateway_routes)?;
    let mut document: Value = serde_json::from_str(&text)?;
    let Some(imports) = document.get_mut("imports").and_then(Value::as_array_mut) else {
        return Ok(());
    };

    let original_len = imports.len();
    let mut kept = Vec::new();
    for item in imports.iter() {
        let Some(import) = item.as_str() else {
            kept.push(item.clone());
            continue;
        };
        let route_file = api_dir.join(import);
        if route_import_is_stale(&root, &route_file)? {
            println!("[sync] removing stale gateway route import: {import}");
            if route_file.starts_with(api_dir.join("routes")) && route_file.exists() {
                fs::remove_file(&route_file)?;
            }
            continue;
        }
        kept.push(item.clone());
    }
    *imports = kept;
    if imports.len() != original_len {
        fs::write(&gateway_routes, gateway_routes_json(&document)?)?;
    }
    Ok(())
}

fn gateway_routes_json(document: &Value) -> Result<String> {
    let default_version = Value::from(1);
    let empty_array = Value::Array(vec![]);
    Ok(format!(
        "{{\n  \"version\": {},\n  \"imports\": {},\n  \"routes\": {},\n  \"upstreams\": {},\n  \"services\": {},\n  \"consumers\": {}\n}}\n",
        serde_json::to_string(document.get("version").unwrap_or(&default_version))?,
        indent_json(document.get("imports").unwrap_or(&empty_array))?,
        indent_json(document.get("routes").unwrap_or(&empty_array))?,
        indent_json(document.get("upstreams").unwrap_or(&empty_array))?,
        indent_json(document.get("services").unwrap_or(&empty_array))?,
        indent_json(document.get("consumers").unwrap_or(&empty_array))?,
    ))
}

fn indent_json(value: &Value) -> Result<String> {
    let text = serde_json::to_string_pretty(value)?;
    Ok(text.replace('\n', "\n  "))
}

pub(crate) fn route_import_is_stale(root: &Path, route_file: &Path) -> Result<bool> {
    if !route_file.exists() {
        return Ok(true);
    }
    let text = fs::read_to_string(route_file)?;
    let document: Value = serde_json::from_str(&text)?;
    let Some(routes) = document.get("routes").and_then(Value::as_array) else {
        return Ok(false);
    };
    for route in routes {
        let Some(proto) = route
            .get("target")
            .and_then(|target| target.get("proto"))
            .and_then(Value::as_str)
        else {
            continue;
        };
        if !root.join(proto).exists() {
            return Ok(true);
        }
    }
    Ok(false)
}

fn prune_orphan_shared_protobufs() -> Result<()> {
    let root = repo_root();
    let active_services = active_service_contract_keys(&root)?;
    let route_protos = gateway_route_proto_paths(&root)?;
    let contracts_dir = root.join("contracts/protobuf");
    if !contracts_dir.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(&contracts_dir)? {
        let path = entry?.path();
        if path.extension().and_then(|extension| extension.to_str()) != Some("proto") {
            continue;
        }
        let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };
        if route_protos.contains(&path) {
            continue;
        }
        let Some(key) = file_name.strip_suffix(".proto") else {
            continue;
        };
        if active_services.contains(key) {
            continue;
        }
        println!("[sync] removing orphan shared protobuf: contracts/protobuf/{file_name}");
        fs::remove_file(path)?;
    }
    Ok(())
}

fn active_service_contract_keys(root: &Path) -> Result<HashSet<String>> {
    let mut keys = HashSet::new();
    collect_active_service_contract_keys(&root.join("services"), &mut keys)?;
    Ok(keys)
}

fn collect_active_service_contract_keys(root: &Path, keys: &mut HashSet<String>) -> Result<()> {
    if !root.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(root)? {
        let path = entry?.path();
        if !path.is_dir() {
            continue;
        }
        let service_yaml = path.join("service.yaml");
        if service_yaml.exists() {
            let text = fs::read_to_string(&service_yaml)?;
            if let (Some(domain), Some(name)) = (
                simple_yaml_value(&text, "domain"),
                simple_yaml_value(&text, "name"),
            ) {
                keys.insert(format!("{domain}-{name}"));
            }
        } else {
            collect_active_service_contract_keys(&path, keys)?;
        }
    }
    Ok(())
}

pub(crate) fn simple_yaml_value(text: &str, key: &str) -> Option<String> {
    let prefix = format!("{key}:");
    text.lines()
        .map(str::trim)
        .find_map(|line| line.strip_prefix(&prefix))
        .map(|value| value.trim().trim_matches(['"', '\'']).to_string())
        .filter(|value| !value.is_empty())
}

fn gateway_route_proto_paths(root: &Path) -> Result<HashSet<std::path::PathBuf>> {
    let mut paths = HashSet::new();
    collect_gateway_route_proto_paths(
        root,
        &root.join("services/core/gateway/api/gateway-routes.json"),
        &mut paths,
    )?;
    Ok(paths)
}

fn collect_gateway_route_proto_paths(
    root: &Path,
    route_source: &Path,
    paths: &mut HashSet<std::path::PathBuf>,
) -> Result<()> {
    if !route_source.exists() {
        return Ok(());
    }
    let text = fs::read_to_string(route_source)?;
    let document: Value = serde_json::from_str(&text)?;
    if let Some(imports) = document.get("imports").and_then(Value::as_array) {
        let base = route_source.parent().unwrap_or(root);
        for import in imports.iter().filter_map(Value::as_str) {
            collect_gateway_route_proto_paths(root, &base.join(import), paths)?;
        }
    }
    if let Some(routes) = document.get("routes").and_then(Value::as_array) {
        for route in routes {
            if let Some(proto) = route
                .get("target")
                .and_then(|target| target.get("proto"))
                .and_then(Value::as_str)
            {
                paths.insert(root.join(proto));
            }
        }
    }
    Ok(())
}

fn run_docs_api_sync() -> Result<()> {
    let docs = repo_root().join("docs");
    if !docs.join("package.json").exists() {
        return Ok(());
    }
    run_cmd(&docs, "bun", &["run".into(), "sync-swagger-openapi".into()])?;
    run_cmd(&docs, "bun", &["run".into(), "generate-api-docs".into()])
}

fn run_release(args: &[String]) -> Result<()> {
    run_make(&repo_root().join("devops"), "release", &make_vars(args))
}

fn run_publish(args: &[String]) -> Result<()> {
    let manifest = repo_root().join("tools/exe/Cargo.toml");
    let max_attempts = publish_max_attempts();
    for attempt in 1..=max_attempts {
        let version = bump_manifest_patch_version(&manifest)?;
        println!("[publish] bumped executesoft crate to {version}");
        let output = cargo_publish(&manifest, args)?;
        if output.status.success() {
            print_command_output(&output);
            return Ok(());
        }
        let stderr = String::from_utf8_lossy(&output.stderr);
        print_command_output(&output);
        if stderr.contains("already exists on crates.io index") && attempt < max_attempts {
            println!("[publish] version {version} already exists; bumping patch and retrying");
            continue;
        }
        return Err(format!("cargo publish failed with {}", output.status).into());
    }
    Err("cargo publish retry limit reached".into())
}

fn cargo_publish(manifest: &Path, args: &[String]) -> Result<std::process::Output> {
    let mut publish_args = vec![
        "publish".to_string(),
        "--manifest-path".to_string(),
        manifest.display().to_string(),
    ];
    publish_args.extend(args.iter().cloned());
    Ok(Command::new("cargo")
        .args(&publish_args)
        .current_dir(repo_root())
        .output()?)
}

fn print_command_output(output: &std::process::Output) {
    print!("{}", String::from_utf8_lossy(&output.stdout));
    eprint!("{}", String::from_utf8_lossy(&output.stderr));
}

fn publish_max_attempts() -> usize {
    std::env::var("EXE_PUBLISH_MAX_VERSION_BUMPS")
        .ok()
        .and_then(|value| value.parse().ok())
        .filter(|value| *value > 0)
        .unwrap_or(10)
}

pub(crate) fn bump_manifest_patch_version(manifest: &Path) -> Result<String> {
    let text = fs::read_to_string(manifest)?;
    let mut next_version = None;
    let mut updated = Vec::new();
    let mut in_package = false;
    for line in text.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('[') {
            in_package = trimmed == "[package]";
        }
        if in_package
            && trimmed.starts_with("version")
            && let Some((key, value)) = line.split_once('=')
        {
            let current = value.trim().trim_matches('"');
            let next = next_patch_version(current)?;
            next_version = Some(next.clone());
            updated.push(format!("{}= \"{}\"", key, next));
            continue;
        }
        updated.push(line.to_string());
    }
    let Some(version) = next_version else {
        return Err(format!("version not found in {}", manifest.display()).into());
    };
    fs::write(manifest, format!("{}\n", updated.join("\n")))?;
    Ok(version)
}

fn next_patch_version(version: &str) -> Result<String> {
    let parts: Vec<&str> = version.split('.').collect();
    if parts.len() != 3 {
        return Err(format!("expected semver major.minor.patch, got {version}").into());
    }
    let major: u64 = parts[0].parse()?;
    let minor: u64 = parts[1].parse()?;
    let patch: u64 = parts[2].parse()?;
    Ok(format!("{major}.{minor}.{}", patch + 1))
}

fn run_release_sync(args: &[String]) -> Result<()> {
    run_make(
        &repo_root().join("devops"),
        "release-sync",
        &make_vars(args),
    )
}