executesoft 0.2.7

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 serde_json::Value;
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 => {
            sync_service_gateway_route_imports()?;
            run_make(&repo_root().join("services/core/gateway"), "generate", &[])
        }
        GatewayCommand::Route { command } => run_gateway_route_command(command),
        GatewayCommand::ValidateRoutes { files } => validate_gateway_routes(&files),
    }
}

pub(crate) fn sync_service_gateway_route_imports() -> Result<()> {
    sync_service_gateway_route_imports_for_root(&repo_root())
}

pub(crate) fn sync_service_gateway_route_imports_for_root(root: &Path) -> Result<()> {
    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 discovered = discover_service_gateway_route_imports(root)?;

    let mut imports = document
        .get("imports")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default()
        .into_iter()
        .filter_map(|value| value.as_str().map(ToString::to_string))
        .filter(|import| !is_discovered_service_import(import))
        .collect::<Vec<_>>();

    imports.extend(discovered);
    imports.sort();
    imports.dedup();

    document["imports"] = Value::Array(imports.into_iter().map(Value::String).collect());
    fs::write(&gateway_routes, gateway_routes_json(&document)?)?;
    Ok(())
}

fn discover_service_gateway_route_imports(root: &Path) -> Result<Vec<String>> {
    let mut route_files = Vec::new();
    collect_service_gateway_route_files(&root.join("services"), &mut route_files)?;
    route_files.sort();

    let api_dir = root.join("services/core/gateway/api");
    let mut imports = Vec::new();
    for route_file in route_files {
        if is_placeholder_route_file(&route_file) {
            println!(
                "[gateway] skipping placeholder service route file: {}",
                route_file.display()
            );
            continue;
        }
        if !gateway_route_file_has_routes(&route_file)? {
            println!(
                "[gateway] skipping empty service route file: {}",
                route_file.display()
            );
            continue;
        }
        validate_gateway_route_file(&route_file)?;
        if gateway_route_file_is_stale(root, &route_file)? {
            println!(
                "[gateway] skipping stale service route file: {}",
                route_file.display()
            );
            continue;
        }
        if gateway_route_file_uses_auth(&route_file)? && !gateway_auth_contract_is_ready(root)? {
            println!(
                "[gateway] skipping auth-protected service route file until auth token contract is ready: {}",
                route_file.display()
            );
            continue;
        }
        imports.push(relative_import_path(&api_dir, &route_file, root)?);
    }
    Ok(imports)
}

fn is_placeholder_route_file(route_file: &Path) -> bool {
    route_file
        .file_name()
        .and_then(OsStr::to_str)
        .is_some_and(|name| name.contains("__"))
}

fn gateway_route_file_has_routes(route_file: &Path) -> Result<bool> {
    let text = fs::read_to_string(route_file)?;
    let document: Value = serde_json::from_str(&text)?;
    Ok(document
        .get("routes")
        .and_then(Value::as_array)
        .is_some_and(|routes| !routes.is_empty()))
}

fn gateway_route_file_uses_auth(route_file: &Path) -> Result<bool> {
    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);
    };
    Ok(routes.iter().any(|route| {
        route
            .get("public")
            .and_then(|public| public.get("auth"))
            .and_then(Value::as_str)
            .is_some_and(|auth| auth != "public")
            || route
                .get("id")
                .and_then(Value::as_str)
                .is_some_and(|id| matches!(id, "auth-login" | "auth-refresh" | "auth-logout"))
    }))
}

fn gateway_auth_contract_is_ready(root: &Path) -> Result<bool> {
    let auth_proto = root.join("services/core/auth/api/service.proto");
    if !auth_proto.exists() {
        return Ok(false);
    }
    let text = fs::read_to_string(auth_proto)?;
    Ok(text.contains("rpc ValidateAccessToken")
        && text.contains("message ValidateAccessTokenRequest")
        && text.contains("message VerifiedIdentity"))
}

fn gateway_route_file_is_stale(root: &Path, route_file: &Path) -> Result<bool> {
    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 collect_service_gateway_route_files(root: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
    if !root.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(root)? {
        let path = entry?.path();
        if path.is_dir() {
            collect_service_gateway_route_files(&path, out)?;
            continue;
        }
        if !path
            .file_name()
            .and_then(OsStr::to_str)
            .is_some_and(|name| name.ends_with("-routes.json"))
        {
            continue;
        }
        if path
            .parent()
            .and_then(Path::file_name)
            .and_then(OsStr::to_str)
            == Some("routes")
            && path
                .parent()
                .and_then(Path::parent)
                .and_then(Path::file_name)
                .and_then(OsStr::to_str)
                == Some("gateway")
        {
            out.push(path);
        }
    }
    Ok(())
}

fn relative_import_path(from_dir: &Path, to_file: &Path, root: &Path) -> Result<String> {
    let from = from_dir.strip_prefix(root)?;
    let to = to_file.strip_prefix(root)?;
    let up_levels = from.components().count();
    let mut parts = vec!["..".to_string(); up_levels];
    parts.extend(
        to.components()
            .map(|component| component.as_os_str().to_string_lossy().to_string()),
    );
    Ok(parts.join("/"))
}

fn is_discovered_service_import(import: &str) -> bool {
    import.contains("/services/") || import.starts_with("../../../services/")
}

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

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 !["public", "user", "permission"].contains(&route.public.auth.as_str()) {
            return usage_error(format!(
                "{}: {}.public.auth must be public, user, 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(())
}