executesoft 0.1.5

ExecuteSoft repository automation CLI
use crate::cli::{GatewayCommand, GatewayRouteCommand};
use crate::util::{Result, repo_path, repo_root, run_make, usage_error};
use serde::Deserialize;
use std::collections::HashSet;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};

pub(crate) fn run_gateway_command(command: GatewayCommand) -> Result<()> {
    match command {
        GatewayCommand::Generate => {
            run_make(&repo_root().join("services/core/gateway"), "generate", &[])
        }
        GatewayCommand::Route { command } => run_gateway_route_command(command),
        GatewayCommand::ValidateRoutes { files } => validate_gateway_routes(&files),
    }
}

fn run_gateway_route_command(command: GatewayRouteCommand) -> Result<()> {
    match command {
        GatewayRouteCommand::Create(args) => {
            run_gateway_route_make("route-file", args.service, None, args.force, args.append)
        }
        GatewayRouteCommand::FromProto(args) => run_gateway_route_make(
            "route-file-from-proto",
            args.service,
            None,
            args.force,
            args.append,
        ),
        GatewayRouteCommand::Entry(args) => run_gateway_route_make(
            "route-entry",
            args.service,
            Some(args.rpc),
            args.force,
            args.append,
        ),
    }
}

fn run_gateway_route_make(
    target: &str,
    service: String,
    rpc: Option<String>,
    force: bool,
    append: bool,
) -> Result<()> {
    let mut vars = vec![format!("SERVICE={service}")];
    if let Some(rpc) = rpc {
        vars.push(format!("RPC={rpc}"));
    }
    if force {
        vars.push("FORCE=true".into());
    }
    if append {
        vars.push("APPEND=true".into());
    }
    run_make(&repo_root().join("services/core/gateway"), target, &vars)
}

#[derive(Deserialize)]
struct RouteModule {
    version: i64,
    routes: Vec<RouteEntry>,
}

#[derive(Deserialize)]
struct RouteEntry {
    id: String,
    public: PublicRoute,
    target: TargetRoute,
}

#[derive(Deserialize)]
struct PublicRoute {
    method: String,
    path: String,
    auth: String,
    #[serde(default)]
    permission: String,
}

#[derive(Deserialize)]
struct TargetRoute {
    protocol: String,
    proto: String,
    package: String,
    service: String,
    rpc: String,
    endpoint_env: String,
}

fn validate_gateway_routes(args: &[String]) -> Result<()> {
    let files: Vec<PathBuf> = if args.is_empty() {
        let routes_dir = env::current_dir()?.join("gateway/routes");
        fs::read_dir(routes_dir)?
            .flatten()
            .map(|entry| entry.path())
            .filter(|path| {
                path.file_name()
                    .and_then(OsStr::to_str)
                    .is_some_and(|name| name.ends_with("-routes.json"))
            })
            .collect()
    } else {
        args.iter().map(|arg| repo_path(arg)).collect()
    };
    if files.is_empty() {
        println!("No gateway route request files found.");
        return Ok(());
    }
    for file in &files {
        validate_gateway_route_file(file)?;
    }
    println!("OK gateway route request files: {}", files.len());
    Ok(())
}

fn validate_gateway_route_file(path: &Path) -> Result<()> {
    let text = fs::read_to_string(path)?;
    let module: RouteModule = serde_json::from_str(&text)?;
    if module.version != 1 {
        return usage_error(format!("{}: version must be 1", path.display()));
    }
    let mut seen = HashSet::new();
    for route in module.routes {
        if route.id.is_empty() {
            return usage_error(format!("{}: route id is required", path.display()));
        }
        if !seen.insert(route.id.clone()) {
            return usage_error(format!(
                "{}: duplicate route id: {}",
                path.display(),
                route.id
            ));
        }
        if !["GET", "POST", "PUT", "PATCH", "DELETE"].contains(&route.public.method.as_str()) {
            return usage_error(format!(
                "{}: {}.public.method is invalid",
                path.display(),
                route.id
            ));
        }
        if !route.public.path.starts_with('/') {
            return usage_error(format!(
                "{}: {}.public.path must start with /",
                path.display(),
                route.id
            ));
        }
        if route.public.auth != "public" && route.public.auth != "permission" {
            return usage_error(format!(
                "{}: {}.public.auth must be public or permission",
                path.display(),
                route.id
            ));
        }
        if route.public.auth == "permission" && route.public.permission.is_empty() {
            return usage_error(format!(
                "{}: {}.public.permission is required for permission auth",
                path.display(),
                route.id
            ));
        }
        if route.target.protocol != "grpc" {
            return usage_error(format!(
                "{}: {}.target.protocol must be grpc",
                path.display(),
                route.id
            ));
        }
        for (field, value) in [
            ("proto", route.target.proto),
            ("package", route.target.package),
            ("service", route.target.service),
            ("rpc", route.target.rpc),
            ("endpoint_env", route.target.endpoint_env),
        ] {
            if value.is_empty() {
                return usage_error(format!(
                    "{}: {}.target.{field} is required",
                    path.display(),
                    route.id
                ));
            }
        }
    }
    Ok(())
}