use crate::config::{Config, ConfigError, Host};
use std::path::Path;
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));
}
}