ssm-core 0.1.1

Core library for ssm — SSH connection, tunnel, and command manager
Documentation
use crate::config::{Config, ConfigError, Host};
use std::path::Path;

/// Write `content` to `path` atomically by first writing a `.tmp` file then
/// renaming it over the target. On POSIX, rename(2) is atomic so readers
/// always see either the old or the new file, never a partial write.
fn atomic_write(path: &Path, content: &str) -> Result<(), std::io::Error> {
    let tmp_path = path.with_extension("tmp");
    std::fs::write(&tmp_path, content)?;
    std::fs::rename(&tmp_path, path)?;
    Ok(())
}

pub fn generate_host_block(host: &Host) -> String {
    let mut lines = vec![format!("Host {}", host.alias)];
    lines.push(format!("    HostName {}", host.hostname));
    if let Some(ref user) = host.user {
        lines.push(format!("    User {}", user));
    }
    if host.port != 22 {
        lines.push(format!("    Port {}", host.port));
    }
    if let Some(ref key) = host.identity_file {
        lines.push(format!("    IdentityFile {}", key.display()));
    }
    lines.join("\n")
}

pub fn generate_config(config: &Config) -> String {
    let header = "# Generated by ssm. Do not edit manually.\n";
    let blocks: Vec<String> = config.hosts.iter().map(generate_host_block).collect();
    format!("{}{}\n", header, blocks.join("\n\n"))
}

pub fn write_generated_config(config: &Config) -> Result<(), ConfigError> {
    let path = &config.settings.generated_config_path;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    atomic_write(path, &generate_config(config))?;
    Ok(())
}

const INCLUDE_DIRECTIVE: &str = "Include ssm-hosts.conf";

pub fn ensure_include_directive(ssh_config_path: &Path) -> Result<(), ConfigError> {
    if let Some(parent) = ssh_config_path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    if ssh_config_path.exists() {
        let content = std::fs::read_to_string(ssh_config_path)?;
        if content.lines().any(|line| line.trim() == INCLUDE_DIRECTIVE) {
            return Ok(());
        }
        let new_content = format!("{}\n\n{}", INCLUDE_DIRECTIVE, content);
        atomic_write(ssh_config_path, &new_content)?;
    } else {
        atomic_write(ssh_config_path, &format!("{}\n", INCLUDE_DIRECTIVE))?;
    }
    Ok(())
}

pub fn sync_ssh_config(config: &Config) -> Result<(), ConfigError> {
    write_generated_config(config)?;
    ensure_include_directive(&config.settings.ssh_config_path)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{Host, Settings};
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn test_host() -> Host {
        Host {
            alias: "prod-api".into(),
            hostname: "10.0.1.50".into(),
            user: Some("deploy".into()),
            port: 22,
            identity_file: Some(PathBuf::from("~/.ssh/id_ed25519")),
            tags: vec!["prod".into()],
            notes: None,
            tunnels: vec![],
            commands: vec![],
        }
    }

    #[test]
    fn test_generate_host_block_basic() {
        let host = test_host();
        let block = generate_host_block(&host);
        assert!(block.contains("Host prod-api"));
        assert!(block.contains("HostName 10.0.1.50"));
        assert!(block.contains("User deploy"));
        assert!(block.contains("IdentityFile ~/.ssh/id_ed25519"));
        assert!(!block.contains("Port"));
    }

    #[test]
    fn test_generate_host_block_custom_port() {
        let mut host = test_host();
        host.port = 2222;
        let block = generate_host_block(&host);
        assert!(block.contains("Port 2222"));
    }

    #[test]
    fn test_generate_config_multiple_hosts() {
        let config = Config {
            settings: Settings::default(),
            hosts: vec![test_host(), {
                let mut h = test_host();
                h.alias = "staging".into();
                h.hostname = "10.0.2.50".into();
                h
            }],
            scenarios: vec![],
        };
        let output = generate_config(&config);
        assert!(output.starts_with("# Generated by ssm"));
        assert!(output.contains("Host prod-api"));
        assert!(output.contains("Host staging"));
    }

    #[test]
    fn test_ensure_include_creates_new_file() {
        let dir = TempDir::new().unwrap();
        let ssh_config = dir.path().join("config");
        ensure_include_directive(&ssh_config).unwrap();
        let content = std::fs::read_to_string(&ssh_config).unwrap();
        assert_eq!(content.trim(), INCLUDE_DIRECTIVE);
    }

    #[test]
    fn test_ensure_include_prepends_to_existing() {
        let dir = TempDir::new().unwrap();
        let ssh_config = dir.path().join("config");
        std::fs::write(&ssh_config, "Host myserver\n    HostName 1.2.3.4\n").unwrap();
        ensure_include_directive(&ssh_config).unwrap();
        let content = std::fs::read_to_string(&ssh_config).unwrap();
        assert!(content.starts_with(INCLUDE_DIRECTIVE));
        assert!(content.contains("Host myserver"));
    }

    #[test]
    fn test_ensure_include_idempotent() {
        let dir = TempDir::new().unwrap();
        let ssh_config = dir.path().join("config");
        std::fs::write(
            &ssh_config,
            format!("{}\n\nHost myserver\n", INCLUDE_DIRECTIVE),
        )
        .unwrap();
        ensure_include_directive(&ssh_config).unwrap();
        let content = std::fs::read_to_string(&ssh_config).unwrap();
        assert_eq!(
            content.matches(INCLUDE_DIRECTIVE).count(),
            1,
            "Include should not be duplicated"
        );
    }

    #[test]
    fn test_sync_ssh_config_end_to_end() {
        let dir = TempDir::new().unwrap();
        let config = Config {
            settings: Settings {
                ssh_config_path: dir.path().join("config"),
                generated_config_path: dir.path().join("ssm-hosts.conf"),
            },
            hosts: vec![test_host()],
            scenarios: vec![],
        };
        sync_ssh_config(&config).unwrap();

        let generated = std::fs::read_to_string(dir.path().join("ssm-hosts.conf")).unwrap();
        assert!(generated.contains("Host prod-api"));

        let ssh_config = std::fs::read_to_string(dir.path().join("config")).unwrap();
        assert!(ssh_config.contains(INCLUDE_DIRECTIVE));
    }
}