executesoft 0.1.9

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;
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 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::RestartCdn(args) => run_make(
            &repo_root().join("devops"),
            "restart-cdn",
            &make_vars(&args.vars),
        ),
        DeployCommand::Recreate(args) => run_make(
            &repo_root().join("devops"),
            "recreate",
            &make_vars(&args.vars),
        ),
    }
}

fn run_sync() -> Result<()> {
    run_cmd(
        &repo_root(),
        "devops/droplet/scripts/sync-service-assets.sh",
        &[],
    )
}

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