susshi 0.15.8

A modern terminal-based SSH connection manager with a beautiful Catppuccin TUI
//! Export au format `~/.ssh/config` depuis l'inventaire susshi.
//!
//! Chaque serveur produit un bloc `Host` avec les directives standard.
//! Les serveurs en mode jump génèrent une directive `ProxyJump`.

use crate::config::{ConnectionMode, ResolvedServer};

pub fn to_openssh_config(servers: &[&ResolvedServer]) -> String {
    let mut out = String::from(
        "# Generated by susshi — do not edit manually\n\
         # susshi --export openssh\n\n",
    );

    for s in servers {
        out.push_str(&format!("Host {}\n", s.name));
        out.push_str(&format!("  HostName {}\n", bare_host(&s.host)));

        if !s.user.is_empty() {
            out.push_str(&format!("  User {}\n", s.user));
        }

        let port = port_from_server(s);
        if port != 22 {
            out.push_str(&format!("  Port {port}\n"));
        }

        if !s.ssh_key.is_empty() {
            let expanded = shellexpand::tilde(&s.ssh_key);
            out.push_str(&format!("  IdentityFile {}\n", expanded));
        }

        if s.default_mode == ConnectionMode::Jump
            && let Some(jump) = &s.jump_host
        {
            out.push_str(&format!("  ProxyJump {jump}\n"));
        }

        if !s.ssh_agent_sock.is_empty() {
            let expanded = shellexpand::tilde(&s.ssh_agent_sock);
            out.push_str(&format!("  IdentityAgent {}\n", expanded));
        }

        out.push('\n');
    }

    out
}

fn bare_host(host: &str) -> &str {
    host.split(':').next().unwrap_or(host)
}

fn port_from_server(s: &ResolvedServer) -> u16 {
    if s.port != 22 {
        return s.port;
    }
    // port embedded in host string
    if let Some(p) = s.host.split(':').nth(1)
        && let Ok(n) = p.parse::<u16>()
    {
        return n;
    }
    22
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::ResolvedServer;

    fn make_server(name: &str, host: &str, mode: ConnectionMode) -> ResolvedServer {
        ResolvedServer {
            name: name.into(),
            host: host.into(),
            user: "ops".into(),
            port: 22,
            ssh_key: String::new(),
            group_name: "grp".into(),
            env_name: String::new(),
            namespace: String::new(),
            tags: vec![],
            notes: String::new(),
            default_mode: mode,
            jump_host: None,
            bastion_host: None,
            bastion_user: None,
            bastion_template: String::new(),
            use_system_ssh_config: false,
            probe_filesystems: vec![],
            tunnels: vec![],
            ssh_options: vec![],
            control_master: false,
            agent_forwarding: false,
            control_path: String::new(),
            control_persist: String::new(),
            pre_connect_hook: None,
            post_disconnect_hook: None,
            hook_timeout_secs: 5,
            ssh_cert: String::new(),
            ssh_agent_sock: String::new(),
            wallix_group: None,
            wallix_account: String::new(),
            wallix_protocol: String::new(),
            wallix_auto_select: false,
            wallix_fail_if_menu_match_error: false,
            wallix_selection_timeout_secs: 8,
            wallix_direct: false,
            wallix_authorization: None,
            wallix_header_columns: vec![],
        }
    }

    #[test]
    fn basic_host_block() {
        let s = make_server("web-01", "198.51.100.1", ConnectionMode::Direct);
        let out = to_openssh_config(&[&s]);
        assert!(out.contains("Host web-01\n"));
        assert!(out.contains("  HostName 198.51.100.1\n"));
        assert!(out.contains("  User ops\n"));
        assert!(!out.contains("Port"), "port 22 ne doit pas apparaître");
    }

    #[test]
    fn non_standard_port() {
        let mut s = make_server("db", "198.51.100.2", ConnectionMode::Direct);
        s.port = 2222;
        let out = to_openssh_config(&[&s]);
        assert!(out.contains("  Port 2222\n"));
    }

    #[test]
    fn port_in_host_string() {
        let s = make_server("db", "198.51.100.2:2222", ConnectionMode::Direct);
        let out = to_openssh_config(&[&s]);
        assert!(
            out.contains("  HostName 198.51.100.2\n"),
            "host sans port attendu"
        );
        assert!(out.contains("  Port 2222\n"));
    }

    #[test]
    fn jump_host_generates_proxyjump() {
        let mut s = make_server("app", "198.51.100.10", ConnectionMode::Jump);
        s.jump_host = Some("jops@jump.example.com".into());
        let out = to_openssh_config(&[&s]);
        assert!(out.contains("  ProxyJump jops@jump.example.com\n"));
    }

    #[test]
    fn identity_file_tilde_expanded() {
        let mut s = make_server("srv", "198.51.100.1", ConnectionMode::Direct);
        s.ssh_key = "~/.ssh/id_ed25519".into();
        let out = to_openssh_config(&[&s]);
        let line = out.lines().find(|l| l.contains("IdentityFile")).unwrap();
        assert!(!line.contains('~'), "tilde doit être expandé");
        assert!(line.contains("/.ssh/id_ed25519"));
    }
}