kaji 0.0.1

Steer your Keycloak configuration to a stable, declared state.
Documentation
use crate::models::{
    AuthenticationFlowRepresentation, AuthenticatorConfigRepresentation, ClientRepresentation,
    ClientScopeRepresentation, ComponentRepresentation, GroupRepresentation,
    IdentityProviderRepresentation, RealmRepresentation, RequiredActionProviderRepresentation,
    RoleRepresentation, UserRepresentation,
};
use crate::utils::ui::{CHECK, SEARCH, SUCCESS, WARN};
use anyhow::{Context, Result};
use console::style;
use serde::de::DeserializeOwned;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::task::JoinSet;

async fn read_yaml_files<T: DeserializeOwned + Send + 'static>(
    dir: &Path,
    file_type: &str,
) -> Result<Vec<(PathBuf, T)>> {
    let mut results = Vec::new();
    if fs::try_exists(dir).await? {
        let mut entries = fs::read_dir(dir).await?;
        let mut join_set = JoinSet::new();
        let file_type_str = file_type.to_string();

        while let Some(entry) = entries.next_entry().await? {
            let path = entry.path();
            if path.extension().is_some_and(|ext| ext == "yaml") {
                let ft = file_type_str.clone();
                join_set.spawn(async move {
                    let content = fs::read_to_string(&path)
                        .await
                        .with_context(|| format!("Failed to read {} file {:?}", ft, path))?;
                    let item: T = serde_yaml::from_str(&content)
                        .with_context(|| format!("Failed to parse {} file {:?}", ft, path))?;
                    Ok::<(PathBuf, T), anyhow::Error>((path, item))
                });
            }
        }

        while let Some(res) = join_set.join_next().await {
            results.push(res??);
        }
    }
    Ok(results)
}

pub async fn run(workspace_dir: PathBuf, realms_to_validate: &[String]) -> Result<()> {
    if !fs::try_exists(&workspace_dir).await? {
        anyhow::bail!("Input directory {:?} does not exist", workspace_dir);
    }

    let realms = if realms_to_validate.is_empty() {
        let mut dirs = Vec::new();
        let mut entries = fs::read_dir(&workspace_dir).await?;
        while let Some(entry) = entries.next_entry().await? {
            if entry.file_type().await?.is_dir() {
                dirs.push(entry.file_name().to_string_lossy().to_string());
            }
        }
        dirs
    } else {
        realms_to_validate.to_vec()
    };

    if realms.is_empty() {
        println!(
            "{} {}",
            WARN,
            style(format!(
                "No realms found to validate in {:?}",
                workspace_dir
            ))
            .yellow()
        );
        return Ok(());
    }

    for realm_name in &realms {
        println!(
            "\n{} {}",
            SEARCH,
            style(format!("Validating realm: {}", realm_name))
                .cyan()
                .bold()
        );
        let realm_dir = workspace_dir.join(realm_name);
        validate_realm(realm_dir).await?;
        println!(
            "  {} {}",
            SUCCESS,
            style(format!("Successfully validated realm: {}", realm_name))
                .green()
                .bold()
        );
    }
    Ok(())
}

async fn validate_realm_config(workspace_dir: &Path) -> Result<()> {
    let realm_path = workspace_dir.join("realm.yaml");
    if !fs::try_exists(&realm_path).await? {
        anyhow::bail!("realm.yaml not found in {:?}", workspace_dir);
    }
    let realm_content = fs::read_to_string(&realm_path)
        .await
        .context("Failed to read realm.yaml")?;
    let realm: RealmRepresentation =
        serde_yaml::from_str(&realm_content).context("Failed to parse realm.yaml")?;

    if realm.realm.is_empty() {
        anyhow::bail!("Realm name is empty in realm.yaml");
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Realm configuration is valid:").dim(),
        style(&realm.realm).green()
    );
    Ok(())
}

fn validate_roles(roles: &[(PathBuf, RoleRepresentation)]) -> Result<()> {
    let mut role_names = HashSet::new();
    for (path, role) in roles {
        if role.name.is_empty() {
            anyhow::bail!("Role name is empty in {:?}", path);
        }
        if role_names.contains(&role.name) {
            anyhow::bail!("Duplicate role name: {}", role.name);
        }
        role_names.insert(role.name.clone());
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Validated roles:").dim(),
        style(roles.len()).green()
    );
    Ok(())
}

fn validate_clients(clients: &[(PathBuf, ClientRepresentation)]) -> Result<()> {
    for (path, client) in clients {
        if client.client_id.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!("Client ID is missing or empty in {:?}", path);
        }
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Validated clients:").dim(),
        style(clients.len()).green()
    );
    Ok(())
}

fn validate_idps(idps: &[(PathBuf, IdentityProviderRepresentation)]) -> Result<()> {
    for (path, idp) in idps {
        if idp.alias.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!("Identity Provider alias is missing or empty in {:?}", path);
        }
        if idp.provider_id.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!(
                "Identity Provider providerId is missing or empty in {:?}",
                path
            );
        }
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Validated Identity Providers:").dim(),
        style(idps.len()).green()
    );
    Ok(())
}

fn validate_client_scopes(scopes: &[(PathBuf, ClientScopeRepresentation)]) -> Result<()> {
    for (path, scope) in scopes {
        if scope.name.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!("Client Scope name is missing or empty in {:?}", path);
        }
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Validated client scopes:").dim(),
        style(scopes.len()).green()
    );
    Ok(())
}

fn validate_groups(groups: &[(PathBuf, GroupRepresentation)]) -> Result<()> {
    for (path, group) in groups {
        if group.name.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!("Group name is missing or empty in {:?}", path);
        }
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Validated groups:").dim(),
        style(groups.len()).green()
    );
    Ok(())
}

fn validate_users(users: &[(PathBuf, UserRepresentation)]) -> Result<()> {
    for (path, user) in users {
        if user.username.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!("User username is missing or empty in {:?}", path);
        }
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Validated users:").dim(),
        style(users.len()).green()
    );
    Ok(())
}

fn validate_authentication_flows(
    flows: &[(PathBuf, AuthenticationFlowRepresentation)],
) -> Result<()> {
    for (path, flow) in flows {
        if flow.alias.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!(
                "Authentication Flow alias is missing or empty in {:?}",
                path
            );
        }
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Validated authentication flows:").dim(),
        style(flows.len()).green()
    );
    Ok(())
}

fn validate_required_actions(
    actions: &[(PathBuf, RequiredActionProviderRepresentation)],
) -> Result<()> {
    for (path, action) in actions {
        if action.alias.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!("Required Action alias is missing or empty in {:?}", path);
        }
        if action.provider_id.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!(
                "Required Action providerId is missing or empty in {:?}",
                path
            );
        }
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Validated required actions:").dim(),
        style(actions.len()).green()
    );
    Ok(())
}

fn validate_authenticator_configs(
    configs: &[(PathBuf, AuthenticatorConfigRepresentation)],
) -> Result<()> {
    for (path, config) in configs {
        if config.alias.as_deref().unwrap_or_default().is_empty() {
            anyhow::bail!(
                "Authenticator Config alias is missing or empty in {:?}",
                path
            );
        }
    }
    println!(
        "  {} {} {}",
        CHECK,
        style("Validated authenticator configs:").dim(),
        style(configs.len()).green()
    );
    Ok(())
}

async fn validate_realm(workspace_dir: PathBuf) -> Result<()> {
    // 1. Validate Realm
    validate_realm_config(&workspace_dir).await?;

    // Read all resource directories concurrently
    let roles_dir = workspace_dir.join("roles");
    let clients_dir = workspace_dir.join("clients");
    let idps_dir = workspace_dir.join("identity-providers");
    let scopes_dir = workspace_dir.join("client-scopes");
    let groups_dir = workspace_dir.join("groups");
    let users_dir = workspace_dir.join("users");
    let flows_dir = workspace_dir.join("authentication-flows");
    let actions_dir = workspace_dir.join("required-actions");
    let configs_dir = workspace_dir.join("authenticator-configs");

    let (roles, clients, idps, scopes, groups, users, flows, actions, configs) = tokio::try_join!(
        read_yaml_files::<RoleRepresentation>(&roles_dir, "role"),
        read_yaml_files::<ClientRepresentation>(&clients_dir, "client"),
        read_yaml_files::<IdentityProviderRepresentation>(&idps_dir, "idp"),
        read_yaml_files::<ClientScopeRepresentation>(&scopes_dir, "client-scope"),
        read_yaml_files::<GroupRepresentation>(&groups_dir, "group"),
        read_yaml_files::<UserRepresentation>(&users_dir, "user"),
        read_yaml_files::<AuthenticationFlowRepresentation>(&flows_dir, "authentication-flow"),
        read_yaml_files::<RequiredActionProviderRepresentation>(&actions_dir, "required-action"),
        read_yaml_files::<AuthenticatorConfigRepresentation>(&configs_dir, "authenticator-config"),
    )?;

    // Validate resources
    validate_roles(&roles)?;
    validate_clients(&clients)?;
    validate_idps(&idps)?;
    validate_client_scopes(&scopes)?;
    validate_groups(&groups)?;
    validate_users(&users)?;
    validate_authentication_flows(&flows)?;
    validate_required_actions(&actions)?;
    validate_authenticator_configs(&configs)?;

    // Validate Components and Keys
    tokio::try_join!(
        validate_components_in_dir(&workspace_dir, "components"),
        validate_components_in_dir(&workspace_dir, "keys")
    )?;

    Ok(())
}

async fn validate_components_in_dir(workspace_dir: &Path, dir_name: &str) -> Result<()> {
    let dir = workspace_dir.join(dir_name);
    if fs::try_exists(&dir).await? {
        let components: Vec<(PathBuf, ComponentRepresentation)> =
            read_yaml_files(&dir, dir_name).await?;
        for (path, component) in &components {
            if let Some(name) = &component.name
                && name.is_empty()
            {
                anyhow::bail!("Component name is empty in {:?}", path);
            }
            if component
                .provider_id
                .as_deref()
                .unwrap_or_default()
                .is_empty()
            {
                anyhow::bail!("Component providerId is missing or empty in {:?}", path);
            }
        }
        println!(
            "  {} {} {}",
            CHECK,
            style(format!("Validated {}:", dir_name)).dim(),
            style(components.len()).green()
        );
    }
    Ok(())
}