auberge 0.9.2

CLI tool for managing self-hosted infrastructure with Ansible
use eyre::Result;
use std::path::PathBuf;

#[derive(Debug, Clone, PartialEq)]
pub struct SshConfigHost {
    pub name: String,
    pub hostname: Option<String>,
    pub user: Option<String>,
    pub port: Option<u16>,
    pub identity_file: Option<String>,
}

pub struct SshConfigParser {
    path: PathBuf,
}

impl SshConfigParser {
    pub fn new() -> Result<Self> {
        let expanded = shellexpand::tilde("~/.ssh/config");
        let path = PathBuf::from(expanded.as_ref());
        Ok(Self { path })
    }

    pub fn parse(&self) -> Result<Vec<SshConfigHost>> {
        if !self.path.exists() {
            return Ok(vec![]);
        }

        let content = std::fs::read_to_string(&self.path)?;
        let hosts = parse_content(&content);

        let filtered: Vec<SshConfigHost> = hosts
            .into_iter()
            .filter(|h| {
                if h.hostname.is_none() {
                    return false;
                }
                let has_wildcard =
                    h.name.contains('*') || h.name.contains('?') || h.name.contains('!');
                !has_wildcard
            })
            .collect();

        Ok(filtered)
    }
}

fn parse_content(content: &str) -> Vec<SshConfigHost> {
    let mut hosts = Vec::new();
    let mut current_host: Option<SshConfigHost> = None;

    for line in content.lines() {
        let trimmed = line.trim();

        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }

        let parts: Vec<&str> = trimmed.split_whitespace().collect();
        if parts.is_empty() {
            continue;
        }

        let directive = parts[0].to_lowercase();

        match directive.as_str() {
            "host" => {
                if let Some(host) = current_host.take() {
                    hosts.push(host);
                }

                if parts.len() > 1 {
                    current_host = Some(SshConfigHost {
                        name: parts[1].to_string(),
                        hostname: None,
                        user: None,
                        port: None,
                        identity_file: None,
                    });
                }
            }
            "hostname" => {
                if let Some(ref mut host) = current_host
                    && parts.len() > 1
                {
                    host.hostname = Some(parts[1].to_string());
                }
            }
            "user" => {
                if let Some(ref mut host) = current_host
                    && parts.len() > 1
                {
                    host.user = Some(parts[1].to_string());
                }
            }
            "port" => {
                if let Some(ref mut host) = current_host
                    && parts.len() > 1
                    && let Ok(port_num) = parts[1].parse::<u16>()
                {
                    host.port = Some(port_num);
                }
            }
            "identityfile" => {
                if let Some(ref mut host) = current_host
                    && parts.len() > 1
                {
                    let expanded = shellexpand::tilde(parts[1]);
                    host.identity_file = Some(expanded.into_owned());
                }
            }
            _ => {}
        }
    }

    if let Some(host) = current_host {
        hosts.push(host);
    }

    hosts
}

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

    #[test]
    fn test_parse_basic_host() {
        let content = r#"
Host myserver
    HostName 192.168.1.100
    User admin
    Port 2222
    IdentityFile ~/.ssh/id_rsa
"#;
        let hosts = parse_content(content);
        assert_eq!(hosts.len(), 1);

        let host = &hosts[0];
        assert_eq!(host.name, "myserver");
        assert_eq!(host.hostname, Some("192.168.1.100".to_string()));
        assert_eq!(host.user, Some("admin".to_string()));
        assert_eq!(host.port, Some(2222));
        assert!(host.identity_file.is_some());
        assert!(host.identity_file.as_ref().unwrap().starts_with("/"));
    }

    #[test]
    fn test_parse_multiple_hosts() {
        let content = r#"
Host server1
    HostName 10.0.0.1
    User root

Host server2
    HostName 10.0.0.2
    User admin
    Port 22
"#;
        let hosts = parse_content(content);
        assert_eq!(hosts.len(), 2);
        assert_eq!(hosts[0].name, "server1");
        assert_eq!(hosts[1].name, "server2");
    }

    #[test]
    fn test_handle_comments_and_blank_lines() {
        let content = r#"
# This is a comment
Host myserver
    # Another comment
    HostName 192.168.1.100

    User admin
"#;
        let hosts = parse_content(content);
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].hostname, Some("192.168.1.100".to_string()));
    }

    #[test]
    fn test_filter_wildcard_hosts() {
        let content = r#"
Host *.example.com
    User admin

Host server1
    HostName 10.0.0.1

Host *
    User defaultuser
"#;
        let hosts = parse_content(content);

        let filtered: Vec<SshConfigHost> = hosts
            .into_iter()
            .filter(|h| {
                if h.hostname.is_none() {
                    return false;
                }
                let has_wildcard =
                    h.name.contains('*') || h.name.contains('?') || h.name.contains('!');
                !has_wildcard
            })
            .collect();

        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].name, "server1");
    }

    #[test]
    fn test_skip_hosts_without_hostname() {
        let content = r#"
Host alias-only
    User admin

Host real-host
    HostName 10.0.0.1
    User admin
"#;
        let hosts = parse_content(content);
        assert_eq!(hosts.len(), 2);

        let filtered: Vec<SshConfigHost> =
            hosts.into_iter().filter(|h| h.hostname.is_some()).collect();

        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].name, "real-host");
    }

    #[test]
    fn test_expand_tilde_in_identity_file() {
        let content = r#"
Host myserver
    HostName 192.168.1.100
    IdentityFile ~/.ssh/custom_key
"#;
        let hosts = parse_content(content);
        assert_eq!(hosts.len(), 1);

        let identity = hosts[0].identity_file.as_ref().unwrap();
        assert!(identity.starts_with('/'));
        assert!(!identity.contains('~'));
    }

    #[test]
    fn test_handle_invalid_port() {
        let content = r#"
Host myserver
    HostName 192.168.1.100
    Port invalid
"#;
        let hosts = parse_content(content);
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].port, None);
    }

    #[test]
    fn test_empty_file_returns_empty_vec() {
        let content = "";
        let hosts = parse_content(content);
        assert_eq!(hosts.len(), 0);
    }

    #[test]
    fn test_only_comments() {
        let content = r#"
# Just comments
# No actual hosts
"#;
        let hosts = parse_content(content);
        assert_eq!(hosts.len(), 0);
    }

    #[test]
    fn test_realistic_ssh_config() {
        let content = r#"
AddKeysToAgent yes

Host github github.com
  HostName github.com
  IdentityFile ~/.ssh/identities/github
  IdentitiesOnly yes
  PreferredAuthentications publickey

Host staging
  HostName 203.0.113.10
  IdentityFile ~/.ssh/identities/staging
  IdentitiesOnly yes
  User deploy
  Port 2209

Host registry
  IdentityFile ~/.ssh/identities/registry
  IdentitiesOnly yes
  User admin

Host production
  HostName 198.51.100.42
  IdentityFile ~/.ssh/identities/production
  IdentitiesOnly yes
  User deploy
  Port 59865

Host *
  ServerAliveInterval 60
"#;
        let hosts = parse_content(content);

        let filtered: Vec<SshConfigHost> = hosts
            .into_iter()
            .filter(|h| {
                if h.hostname.is_none() {
                    return false;
                }
                let has_wildcard =
                    h.name.contains('*') || h.name.contains('?') || h.name.contains('!');
                !has_wildcard
            })
            .collect();

        assert_eq!(filtered.len(), 3);

        let github = filtered.iter().find(|h| h.name == "github").unwrap();
        assert_eq!(github.hostname, Some("github.com".to_string()));
        assert!(github.identity_file.is_some());

        let staging = filtered.iter().find(|h| h.name == "staging").unwrap();
        assert_eq!(staging.hostname, Some("203.0.113.10".to_string()));
        assert_eq!(staging.user, Some("deploy".to_string()));
        assert_eq!(staging.port, Some(2209));

        let production = filtered.iter().find(|h| h.name == "production").unwrap();
        assert_eq!(production.port, Some(59865));

        assert!(filtered.iter().all(|h| !h.name.contains('*')));
    }
}