pub mod app_config;
pub mod snippets;
pub mod ssh_config;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::ssh::client::{Host, HostSource};
use crate::utils::platform;
#[derive(Debug, Default, Serialize, Deserialize)]
struct HostsFile {
#[serde(default)]
hosts: Vec<Host>,
}
pub fn load_hosts() -> anyhow::Result<Vec<Host>> {
let path = platform::hosts_config_path().context("Cannot determine hosts config path")?;
if !path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let file: HostsFile =
toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(file.hosts)
}
pub fn save_hosts(hosts: &[Host]) -> anyhow::Result<()> {
let dir = platform::app_config_dir().context("Cannot determine app config directory")?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create directory {}", dir.display()))?;
let path = dir.join("hosts.toml");
let manual: Vec<Host> = hosts
.iter()
.filter(|h| h.source == HostSource::Manual)
.cloned()
.collect();
let file = HostsFile { hosts: manual };
let content = toml::to_string_pretty(&file).context("Failed to serialise hosts")?;
let tmp_path = path.with_extension("toml.tmp");
std::fs::write(&tmp_path, content)
.with_context(|| format!("Failed to write {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &path).with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
pub fn load_all_hosts() -> anyhow::Result<Vec<Host>> {
let manual = load_hosts()?;
let mut ssh_hosts: Vec<Host> = Vec::new();
if let Some(ssh_path) = platform::ssh_config_path() {
if ssh_path.exists() {
match ssh_config::load_from_file(&ssh_path) {
Ok(h) => ssh_hosts = h,
Err(e) => tracing::warn!("SSH config parse error: {}", e),
}
}
}
Ok(merge_hosts(manual, ssh_hosts))
}
pub(crate) fn merge_hosts(manual: Vec<Host>, ssh_hosts: Vec<Host>) -> Vec<Host> {
let manual_names: std::collections::HashSet<String> =
manual.iter().map(|h| h.name.clone()).collect();
let renamed_ssh_hosts: std::collections::HashSet<String> = manual
.iter()
.filter_map(|h| h.original_ssh_host.clone())
.collect();
let mut all = manual;
for h in ssh_hosts {
if !manual_names.contains(&h.name) && !renamed_ssh_hosts.contains(&h.name) {
all.push(h);
}
}
all
}
#[cfg(test)]
mod tests {
use super::*;
fn host(name: &str, source: HostSource) -> Host {
Host {
name: name.to_string(),
source,
..Host::default()
}
}
fn renamed(name: &str, original: &str) -> Host {
Host {
name: name.to_string(),
source: HostSource::Manual,
original_ssh_host: Some(original.to_string()),
..Host::default()
}
}
fn names(hosts: &[Host]) -> Vec<&str> {
hosts.iter().map(|h| h.name.as_str()).collect()
}
#[test]
fn merge_empty_both() {
assert!(merge_hosts(vec![], vec![]).is_empty());
}
#[test]
fn merge_manual_only() {
let out = merge_hosts(vec![host("a", HostSource::Manual)], vec![]);
assert_eq!(names(&out), ["a"]);
}
#[test]
fn merge_ssh_only() {
let out = merge_hosts(vec![], vec![host("a", HostSource::SshConfig)]);
assert_eq!(names(&out), ["a"]);
}
#[test]
fn merge_manual_listed_before_ssh() {
let out = merge_hosts(
vec![host("m", HostSource::Manual)],
vec![host("s", HostSource::SshConfig)],
);
assert_eq!(names(&out), ["m", "s"]);
}
#[test]
fn merge_name_collision_manual_wins() {
let out = merge_hosts(
vec![host("web", HostSource::Manual)],
vec![host("web", HostSource::SshConfig)],
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].source, HostSource::Manual);
}
#[test]
fn merge_renamed_ssh_host_excluded() {
let out = merge_hosts(
vec![renamed("new", "old")],
vec![host("old", HostSource::SshConfig)],
);
assert_eq!(names(&out), ["new"]);
}
#[test]
fn merge_renamed_and_collision_combined() {
let out = merge_hosts(
vec![renamed("a", "b")],
vec![
host("a", HostSource::SshConfig),
host("b", HostSource::SshConfig),
],
);
assert_eq!(names(&out), ["a"]);
}
#[test]
fn merge_multiple_ssh_distinct_names_kept() {
let out = merge_hosts(
vec![],
vec![
host("a", HostSource::SshConfig),
host("b", HostSource::SshConfig),
host("c", HostSource::SshConfig),
],
);
assert_eq!(names(&out), ["a", "b", "c"]);
}
#[test]
fn merge_rename_keeps_unrelated_ssh_host() {
let out = merge_hosts(
vec![renamed("a", "b")],
vec![
host("b", HostSource::SshConfig),
host("c", HostSource::SshConfig),
],
);
assert_eq!(names(&out), ["a", "c"]);
}
#[test]
fn hostsfile_roundtrip_preserves_fields() {
let mut h = host("web", HostSource::Manual);
h.hostname = "10.0.0.1".to_string();
h.user = "deploy".to_string();
h.port = 2222;
h.tags = vec!["prod".to_string()];
let toml_str = toml::to_string_pretty(&HostsFile { hosts: vec![h] }).unwrap();
let parsed: HostsFile = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.hosts.len(), 1);
let g = &parsed.hosts[0];
assert_eq!(g.name, "web");
assert_eq!(g.hostname, "10.0.0.1");
assert_eq!(g.user, "deploy");
assert_eq!(g.port, 2222);
assert_eq!(g.tags, vec!["prod"]);
}
#[test]
fn hostsfile_save_filter_keeps_only_manual() {
let hosts = [
host("m", HostSource::Manual),
host("s", HostSource::SshConfig),
];
let manual: Vec<Host> = hosts
.iter()
.filter(|h| h.source == HostSource::Manual)
.cloned()
.collect();
assert_eq!(names(&manual), ["m"]);
}
#[test]
fn hostsfile_empty_input_parses_to_empty() {
let parsed: HostsFile = toml::from_str("").unwrap();
assert!(parsed.hosts.is_empty());
}
#[test]
fn hostsfile_omits_none_optional_fields() {
let toml_str = toml::to_string_pretty(&HostsFile {
hosts: vec![host("a", HostSource::Manual)],
})
.unwrap();
assert!(!toml_str.contains("identity_file"));
assert!(!toml_str.contains("password"));
}
#[test]
fn hostsfile_password_survives_roundtrip() {
let mut h = host("a", HostSource::Manual);
h.password = Some("secret".to_string());
let toml_str = toml::to_string_pretty(&HostsFile { hosts: vec![h] }).unwrap();
let parsed: HostsFile = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.hosts[0].password.as_deref(), Some("secret"));
}
}