kaji 0.0.1

Steer your Keycloak configuration to a stable, declared state.
Documentation
use crate::client::KeycloakClient;
use crate::models::{ComponentRepresentation, KeycloakResource};
use crate::utils::secrets::{SecretResolver, substitute_secrets};
use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs as async_fs;
use tokio::task::JoinSet;

pub type ComponentKey = (
    Option<String>,
    Option<String>,
    Option<String>,
    Option<String>,
);

pub fn build_component_indices(
    existing_components: impl IntoIterator<Item = ComponentRepresentation>,
) -> (
    HashMap<String, ComponentRepresentation>,
    HashMap<ComponentKey, ComponentRepresentation>,
) {
    let mut by_identity: HashMap<String, ComponentRepresentation> = HashMap::new();
    let mut by_details: HashMap<ComponentKey, ComponentRepresentation> = HashMap::new();

    for c in existing_components {
        if let Some(id) = c.get_identity() {
            by_identity.insert(id, c.clone());
        }
        let key = (
            c.name.clone(),
            c.sub_type.clone(),
            c.provider_id.clone(),
            c.parent_id.clone(),
        );
        by_details.insert(key, c);
    }
    (by_identity, by_details)
}

use crate::utils::yaml::{is_overlay_file, load_yaml_with_overlay};

pub async fn process_component_file(
    path: PathBuf,
    client: KeycloakClient,
    by_identity: Arc<HashMap<String, ComponentRepresentation>>,
    by_details: Arc<HashMap<ComponentKey, ComponentRepresentation>>,
    resolver: Arc<dyn SecretResolver>,
    realm_name: String,
    profile: Option<String>,
) -> Result<()> {
    let mut val = load_yaml_with_overlay(&path, profile.as_deref()).await?;
    substitute_secrets(&mut val, Arc::clone(&resolver)).await?;
    let mut component_rep: ComponentRepresentation = serde_json::from_value(val)?;

    let existing = if let Some(identity) = component_rep.get_identity() {
        by_identity.get(&identity).or_else(|| {
            let key = (
                component_rep.name.clone(),
                component_rep.sub_type.clone(),
                component_rep.provider_id.clone(),
                component_rep.parent_id.clone(),
            );
            by_details.get(&key)
        })
    } else {
        let key = (
            component_rep.name.clone(),
            component_rep.sub_type.clone(),
            component_rep.provider_id.clone(),
            component_rep.parent_id.clone(),
        );
        by_details.get(&key)
    };

    crate::handle_upsert! {
        client: client,
        realm: realm_name,
        rep: component_rep,
        id_opt: existing.and_then(|e| e.id.as_ref()),
        id_field: id,
        resource_name: "component",
        update_call: |id, rep| client.update_component(id, rep),
        create_call: |rep| client.create_component(rep)
    }
    Ok(())
}

pub async fn apply_components_or_keys(
    client: &KeycloakClient,
    workspace_dir: &std::path::Path,
    dir_name: &str,
    resolver: Arc<dyn SecretResolver>,
    planned_files: Arc<Option<HashSet<PathBuf>>>,
    realm_name: &str,
    profile: Option<String>,
) -> Result<()> {
    let components_dir = workspace_dir.join(dir_name);
    if !async_fs::try_exists(&components_dir).await? {
        return Ok(());
    }

    let existing_components = client
        .get_components()
        .await
        .with_context(|| format!("Failed to get components/keys for realm '{}'", realm_name))?;

    let (by_identity, by_details) = build_component_indices(existing_components);
    let by_identity = Arc::new(by_identity);
    let by_details = Arc::new(by_details);

    let mut entries = async_fs::read_dir(&components_dir).await?;
    let mut set = JoinSet::new();

    while let Some(entry) = entries.next_entry().await? {
        let path = entry.path();
        if let Some(plan) = &*planned_files
            && !plan.contains(&path)
        {
            continue;
        }
        if path.extension().is_none_or(|ext| ext != "yaml") {
            continue;
        }
        // Skip overlay files themselves
        if is_overlay_file(&path, profile.as_deref()) {
            continue;
        }

        let client = client.clone();
        let by_identity = Arc::clone(&by_identity);
        let by_details = Arc::clone(&by_details);
        let resolver = Arc::clone(&resolver);
        let realm_name = realm_name.to_string();
        let profile = profile.clone();
        set.spawn(async move {
            process_component_file(
                path,
                client,
                by_identity,
                by_details,
                resolver,
                realm_name,
                profile,
            )
            .await
        });
    }
    crate::utils::join_all_tasks(set, None).await?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use crate::apply::test_utils::start_mock_server;

    use super::*;
    use crate::client::KeycloakClient;
    use crate::utils::secrets::EnvResolver;

    use std::fs;
    use std::sync::Arc;
    use tempfile::tempdir;

    #[test]
    fn test_build_component_indices() {
        let comps = vec![
            ComponentRepresentation {
                id: Some("id1".to_string()),
                name: Some("comp1".to_string()),
                provider_id: Some("prov1".to_string()),
                provider_type: Some("type1".to_string()),
                sub_type: Some("sub1".to_string()),
                parent_id: Some("parent1".to_string()),
                config: None,
                extra: Default::default(),
            },
            ComponentRepresentation {
                id: None,
                name: Some("comp2".to_string()),
                provider_id: Some("prov2".to_string()),
                provider_type: Some("type2".to_string()),
                sub_type: None,
                parent_id: Some("parent2".to_string()),
                config: None,
                extra: Default::default(),
            },
        ];

        let (by_identity, by_details) = build_component_indices(comps);

        // get_identity for ComponentRepresentation returns `id.or_else(|| name)`.
        // First component has id "id1", so it uses "id1".
        // Second component has no id, so it uses "comp2".
        assert_eq!(by_identity.len(), 2);
        assert!(by_identity.contains_key("id1"));
        assert!(by_identity.contains_key("comp2"));

        assert_eq!(by_details.len(), 2);
        let key1 = (
            Some("comp1".to_string()),
            Some("sub1".to_string()),
            Some("prov1".to_string()),
            Some("parent1".to_string()),
        );
        let key2 = (
            Some("comp2".to_string()),
            None,
            Some("prov2".to_string()),
            Some("parent2".to_string()),
        );
        assert!(by_details.contains_key(&key1));
        assert!(by_details.contains_key(&key2));
    }

    #[tokio::test]
    async fn test_apply_components_error_paths() -> Result<()> {
        let (server_url, call_count) = start_mock_server().await?;
        let mut client = KeycloakClient::new(server_url);
        client.set_target_realm("test".to_string());
        client.set_token("mock_token".to_string());

        let temp = tempdir()?;
        let components_dir = temp.path().join("components");
        fs::create_dir(&components_dir)?;
        let resolver = Arc::new(EnvResolver::new(HashMap::new()));

        // 1. Test update failure
        call_count.store(0, std::sync::atomic::Ordering::SeqCst);
        let comp_existing = components_dir.join("existing.yaml");
        fs::write(comp_existing, "name: Existing Component\nid: existing-id")?;

        let res = apply_components_or_keys(
            &client,
            temp.path(),
            "components",
            Arc::clone(&resolver) as Arc<dyn SecretResolver>,
            Arc::new(None),
            "test",
            None,
        )
        .await;
        assert!(res.is_err());
        assert!(
            res.unwrap_err()
                .to_string()
                .contains("Failed to update component")
        );

        fs::remove_file(components_dir.join("existing.yaml"))?;

        // 2. Test create failure
        call_count.store(0, std::sync::atomic::Ordering::SeqCst);
        let comp_new = components_dir.join("new.yaml");
        fs::write(comp_new, "name: New Component\nproviderId: new-provider")?;

        let res = apply_components_or_keys(
            &client,
            temp.path(),
            "components",
            Arc::clone(&resolver) as Arc<dyn SecretResolver>,
            Arc::new(None),
            "test",
            None,
        )
        .await;
        assert!(res.is_err());
        assert!(
            res.unwrap_err()
                .to_string()
                .contains("Failed to create component")
        );

        Ok(())
    }
}