susshi 0.24.0

Terminal SSH manager with YAML inventories, multi-hop jump hosts, Wallix bastion, tunnels, SCP, and Catppuccin TUI
use super::*;

/// Represents a single entry from a Wallix menu.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WallixMenuEntry {
    /// Menu entry ID (as String to preserve leading zeros).
    pub id: String,
    /// Target in format "user@account@host:protocol".
    pub target: String,
    /// Authorization group name.
    pub group: String,
}

/// Build the expected Wallix target string from a resolved server.
///
/// Format: `user@account@host:protocol`
pub fn build_expected_target(server: &ResolvedServer) -> String {
    format!(
        "{}@{}@{}:{}",
        server.user, server.wallix_account, server.host, server.wallix_protocol
    )
}

fn normalize_target_segment(value: &str) -> String {
    let mut normalized = String::new();
    let mut previous_was_dash = false;

    for character in value.trim().chars() {
        if character.is_ascii_alphanumeric() {
            normalized.push(character.to_ascii_uppercase());
            previous_was_dash = false;
        } else if !previous_was_dash {
            normalized.push('-');
            previous_was_dash = true;
        }
    }

    normalized.trim_matches('-').to_string()
}

fn infer_wallix_role(server: &ResolvedServer) -> Option<String> {
    let first_host_label = server
        .host
        .split('.')
        .next()
        .unwrap_or(server.host.as_str());
    let host_last_token = first_host_label
        .rsplit('-')
        .next()
        .unwrap_or(first_host_label);
    let source = if !server.name.trim().is_empty() {
        server.name.as_str()
    } else {
        host_last_token
    };

    let role_key: String = source
        .chars()
        .take_while(|character| !character.is_ascii_digit())
        .collect::<String>()
        .to_ascii_lowercase();

    let role = match role_key.as_str() {
        "bdd" | "bd" | "db" => "BD",
        "apps" | "app" | "appli" => "APPLI",
        "adm" | "admin" => "ADMIN",
        "web" => "WEB",
        "kafka" => "KAFKA",
        "els" => "ELS",
        "mig" => "MIG",
        "idp" => "IDP",
        "frt" | "frtrac" => "FRTRAC",
        other if !other.is_empty() => return Some(normalize_target_segment(other)),
        _ => return None,
    };

    Some(role.to_string())
}

pub fn build_expected_targets(server: &ResolvedServer) -> Vec<String> {
    let mut candidates = vec![build_expected_target(server)];

    let first_host_label = server
        .host
        .split('.')
        .next()
        .unwrap_or(server.host.as_str());
    let short_host = normalize_target_segment(first_host_label);
    if !short_host.is_empty() {
        candidates.push(format!(
            "{}@{}@{}:{}",
            server.user, server.wallix_account, short_host, server.wallix_protocol
        ));
    }

    let env = if !server.env_name.trim().is_empty() {
        normalize_target_segment(&server.env_name)
    } else {
        normalize_target_segment(first_host_label.split('-').next().unwrap_or_default())
    };

    let project_from_domain = server.host.split('.').nth(1).map(normalize_target_segment);
    let project_from_group = if server.group_name.trim().is_empty() {
        String::new()
    } else {
        normalize_target_segment(&server.group_name)
    };

    let role = infer_wallix_role(server).unwrap_or_default();

    for project in [project_from_domain.unwrap_or_default(), project_from_group] {
        if !env.is_empty() && !project.is_empty() && !role.is_empty() {
            candidates.push(format!(
                "{}@{}@{}-{}-{}:{}",
                server.user, server.wallix_account, env, project, role, server.wallix_protocol
            ));
        }
    }

    candidates.dedup();
    candidates
}

fn normalize_authorization_segment(value: &str) -> String {
    let mut normalized = String::new();
    let mut previous_was_dash = false;

    for character in value.trim().chars() {
        if character.is_ascii_alphanumeric() {
            normalized.push(character.to_ascii_uppercase());
            previous_was_dash = false;
        } else if !previous_was_dash {
            normalized.push('-');
            previous_was_dash = true;
        }
    }

    normalized.trim_matches('-').to_string()
}

/// Autorisations Wallix déclarées en config, par ordre de préférence :
/// `wallix.authorization` (nom exact) puis `wallix.authorizations`.
///
/// Vide si rien n'est configuré : il faut alors recourir à la détection
/// (nom dérivé de la structure YAML, puis menu Wallix).
pub fn configured_authorizations(server: &ResolvedServer) -> Vec<String> {
    let mut list: Vec<String> = Vec::new();
    let explicit = server
        .wallix_authorization
        .as_deref()
        .map(str::trim)
        .filter(|a| !a.is_empty());
    for auth in explicit
        .into_iter()
        .chain(server.wallix_authorizations.iter().map(String::as_str))
    {
        if !list.iter().any(|a| a == auth) {
            list.push(auth.to_string());
        }
    }
    list
}

/// Autorisation à utiliser pour une connexion directe (sans menu) : présente
/// uniquement si la config en désigne exactement une. Avec plusieurs
/// candidates, c'est le menu Wallix qui départage.
pub fn direct_authorization(server: &ResolvedServer) -> Option<String> {
    let mut auths = configured_authorizations(server);
    if auths.len() == 1 { auths.pop() } else { None }
}

/// Login Wallix « direct » (sans menu) : `user@cible+PROTO[+autorisation]+bastion_user`.
///
/// Le port éventuel de `server.host` est retiré : c'est le bastion qui joint la cible.
pub fn build_direct_login(server: &ResolvedServer, authorization: Option<&str>) -> String {
    let target_host = server
        .host
        .split_once(':')
        .map_or(server.host.as_str(), |(h, _)| h);
    let bastion_user = server.bastion_user.as_deref().unwrap_or("root");
    let mut login = format!("{}@{}+{}", server.user, target_host, server.wallix_protocol);
    if let Some(auth) = authorization.map(str::trim).filter(|a| !a.is_empty()) {
        login.push('+');
        login.push_str(auth);
    }
    login.push('+');
    login.push_str(bastion_user);
    login
}

/// Autorisations candidates pour la sélection dans le menu Wallix, par ordre
/// de préférence : celles déclarées en config, puis la détection (suffixe
/// `group` seul, puis `<ENV>-<GROUPE>_<group>` dérivé de la structure YAML).
pub fn build_expected_groups(server: &ResolvedServer) -> Result<Vec<String>> {
    let mut candidates = configured_authorizations(server);

    let configured_group = match server
        .wallix_group
        .as_deref()
        .map(str::trim)
        .filter(|g| !g.is_empty())
    {
        Some(g) => g,
        None => return Ok(candidates),
    };

    candidates.push(configured_group.to_string());

    if !configured_group.contains('_') {
        let mut prefix_parts = Vec::new();
        if !server.env_name.trim().is_empty() {
            let env = normalize_authorization_segment(&server.env_name);
            if !env.is_empty() {
                prefix_parts.push(env);
            }
        }
        if !server.group_name.trim().is_empty() {
            let group = normalize_authorization_segment(&server.group_name);
            if !group.is_empty() {
                prefix_parts.push(group);
            }
        }

        if !prefix_parts.is_empty() {
            candidates.push(format!("{}_{}", prefix_parts.join("-"), configured_group));
        }
    }

    let mut unique: Vec<String> = Vec::with_capacity(candidates.len());
    for c in candidates {
        if !unique.contains(&c) {
            unique.push(c);
        }
    }
    Ok(unique)
}

pub(crate) fn group_suffix_matches(entry_group: &str, configured_group: &str) -> bool {
    let entry_lower = entry_group.to_ascii_lowercase();
    let conf_lower = configured_group.to_ascii_lowercase();
    entry_lower == conf_lower || entry_lower.ends_with(&format!("_{conf_lower}"))
}