use crate::error::{AppError, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SshConfigEntry {
pub host: String,
pub hostname: Option<String>,
pub port: Option<u16>,
pub user: Option<String>,
pub identity_file: Option<PathBuf>,
}
#[derive(Debug, Clone, Default)]
struct SshConfigDefaults {
port: Option<u16>,
user: Option<String>,
identity_file: Option<PathBuf>,
}
pub fn parse_ssh_config(path: impl AsRef<Path>) -> Result<Vec<SshConfigEntry>> {
let path = expand_tilde(path.as_ref())?;
let content = std::fs::read_to_string(&path)
.map_err(|e| AppError::Config(format!("Failed to read SSH config file: {}", e)))?;
let entries = parse_ssh_config_content(&content)?;
Ok(entries)
}
pub fn default_ssh_config_path() -> PathBuf {
if let Some(mut home) = dirs::home_dir() {
home.push(".ssh");
home.push("config");
home
} else {
PathBuf::from("~/.ssh/config")
}
}
fn expand_tilde(path: &Path) -> Result<PathBuf> {
let path_str = path.to_string_lossy();
if path_str == "~" {
if let Some(home) = dirs::home_dir() {
Ok(home)
} else {
Err(AppError::Config(
"Cannot expand ~: home directory not found".to_string(),
))
}
} else if let Some(rest) = path_str.strip_prefix("~/") {
if let Some(mut home) = dirs::home_dir() {
home.push(rest);
Ok(home)
} else {
Err(AppError::Config(
"Cannot expand ~: home directory not found".to_string(),
))
}
} else {
Ok(path.to_path_buf())
}
}
fn parse_ssh_config_content(content: &str) -> Result<Vec<SshConfigEntry>> {
let mut entries = Vec::new();
let mut current_host: Option<String> = None;
let mut current_config: HashMap<String, String> = HashMap::new();
let mut defaults = SshConfigDefaults::default();
let mut is_default_host = false;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with("Host ") {
if let Some(host) = current_host.take() {
if !is_default_host {
if let Some(entry) = build_entry(&host, ¤t_config, &defaults) {
entries.push(entry);
}
} else {
defaults = extract_defaults(¤t_config);
}
}
current_config.clear();
is_default_host = false;
let hosts = line[4..].trim();
let host = hosts.split_whitespace().next().unwrap_or("").to_string();
if host == "*" {
is_default_host = true;
current_host = Some(host);
} else if !host.is_empty() {
current_host = Some(host);
}
} else if current_host.is_some() {
if let Some((key, value)) = parse_directive(line) {
current_config.insert(key.to_lowercase(), value);
}
}
}
if let Some(host) = current_host {
if !is_default_host {
if let Some(entry) = build_entry(&host, ¤t_config, &defaults) {
entries.push(entry);
}
} else {
let _ = extract_defaults(¤t_config);
}
}
Ok(entries)
}
fn extract_defaults(config: &HashMap<String, String>) -> SshConfigDefaults {
SshConfigDefaults {
port: config.get("port").and_then(|p| p.parse::<u16>().ok()),
user: config.get("user").cloned(),
identity_file: config
.get("identityfile")
.and_then(|p| expand_tilde_in_path(p)),
}
}
fn parse_directive(line: &str) -> Option<(&str, String)> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let key = parts[0];
let value = parts[1..].join(" "); Some((key, value))
} else {
None
}
}
fn build_entry(
host: &str,
config: &HashMap<String, String>,
defaults: &SshConfigDefaults,
) -> Option<SshConfigEntry> {
let hostname = config.get("hostname")?.clone();
let port = config
.get("port")
.and_then(|p| p.parse::<u16>().ok())
.or(defaults.port);
let user = config
.get("user")
.cloned()
.or_else(|| defaults.user.clone());
let identity_file = config
.get("identityfile")
.and_then(|p| expand_tilde_in_path(p))
.or_else(|| defaults.identity_file.clone());
Some(SshConfigEntry {
host: host.to_string(),
hostname: Some(hostname),
port,
user,
identity_file,
})
}
fn expand_tilde_in_path(path: &str) -> Option<PathBuf> {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(mut home) = dirs::home_dir() {
home.push(rest);
Some(home)
} else {
None
}
} else {
Some(PathBuf::from(path))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_ssh_config() {
let content = r#"
Host myserver
HostName example.com
Port 22
User myuser
IdentityFile ~/.ssh/id_rsa
Host myserver2
HostName example2.com
User user2
"#;
let entries = parse_ssh_config_content(content).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].host, "myserver");
assert_eq!(entries[0].hostname, Some("example.com".to_string()));
assert_eq!(entries[0].port, Some(22));
assert_eq!(entries[0].user, Some("myuser".to_string()));
assert_eq!(entries[1].host, "myserver2");
assert_eq!(entries[1].hostname, Some("example2.com".to_string()));
assert_eq!(entries[1].user, Some("user2".to_string()));
}
#[test]
fn test_default_values_from_wildcard_host() {
let content = r#"
Host *
Port 2222
User defaultuser
IdentityFile ~/.ssh/default_key
Host myserver
HostName example.com
Host myserver2
HostName example2.com
Port 22
User customuser
"#;
let entries = parse_ssh_config_content(content).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].host, "myserver");
assert_eq!(entries[0].hostname, Some("example.com".to_string()));
assert_eq!(entries[0].port, Some(2222)); assert_eq!(entries[0].user, Some("defaultuser".to_string())); assert!(entries[0].identity_file.is_some());
assert_eq!(entries[1].host, "myserver2");
assert_eq!(entries[1].hostname, Some("example2.com".to_string()));
assert_eq!(entries[1].port, Some(22)); assert_eq!(entries[1].user, Some("customuser".to_string())); }
#[test]
fn test_wildcard_host_not_included_in_entries() {
let content = r#"
Host *
Port 2222
User defaultuser
Host myserver
HostName example.com
"#;
let entries = parse_ssh_config_content(content).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].host, "myserver");
}
}