1pub mod app_config;
11pub mod snippets;
12pub mod ssh_config;
13
14use anyhow::Context;
15use serde::{Deserialize, Serialize};
16
17use crate::ssh::client::{Host, HostSource};
18use crate::utils::platform;
19
20#[derive(Debug, Default, Serialize, Deserialize)]
26struct HostsFile {
27 #[serde(default)]
28 hosts: Vec<Host>,
29}
30
31pub fn load_hosts() -> anyhow::Result<Vec<Host>> {
38 let path = platform::hosts_config_path().context("Cannot determine hosts config path")?;
39
40 if !path.exists() {
41 return Ok(Vec::new());
42 }
43
44 let content = std::fs::read_to_string(&path)
45 .with_context(|| format!("Failed to read {}", path.display()))?;
46
47 let file: HostsFile =
48 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
49
50 Ok(file.hosts)
51}
52
53pub fn save_hosts(hosts: &[Host]) -> anyhow::Result<()> {
63 let dir = platform::app_config_dir().context("Cannot determine app config directory")?;
64
65 std::fs::create_dir_all(&dir)
66 .with_context(|| format!("Failed to create directory {}", dir.display()))?;
67
68 let path = dir.join("hosts.toml");
69
70 let manual: Vec<Host> = hosts
71 .iter()
72 .filter(|h| h.source == HostSource::Manual)
73 .cloned()
74 .collect();
75
76 let file = HostsFile { hosts: manual };
77 let content = toml::to_string_pretty(&file).context("Failed to serialise hosts")?;
78
79 let tmp_path = path.with_extension("toml.tmp");
82 std::fs::write(&tmp_path, content)
83 .with_context(|| format!("Failed to write {}", tmp_path.display()))?;
84 if let Err(e) = std::fs::rename(&tmp_path, &path) {
85 let _ = std::fs::remove_file(&tmp_path);
86 return Err(e).with_context(|| {
87 format!(
88 "Failed to rename {} to {}",
89 tmp_path.display(),
90 path.display()
91 )
92 });
93 }
94 #[cfg(unix)]
95 {
96 use std::os::unix::fs::PermissionsExt;
97 let perms = std::fs::Permissions::from_mode(0o600);
98 let _ = std::fs::set_permissions(&path, perms);
99 }
100
101 Ok(())
102}
103
104pub fn load_all_hosts() -> anyhow::Result<Vec<Host>> {
114 let manual = load_hosts()?;
116
117 let mut ssh_hosts: Vec<Host> = Vec::new();
119 if let Some(ssh_path) = platform::ssh_config_path() {
120 if ssh_path.exists() {
121 match ssh_config::load_from_file(&ssh_path) {
122 Ok(h) => ssh_hosts = h,
123 Err(e) => tracing::warn!("SSH config parse error: {}", e),
124 }
125 }
126 }
127
128 Ok(merge_hosts(manual, ssh_hosts))
130}
131
132pub(crate) fn merge_hosts(manual: Vec<Host>, ssh_hosts: Vec<Host>) -> Vec<Host> {
138 let manual_names: std::collections::HashSet<String> =
139 manual.iter().map(|h| h.name.clone()).collect();
140
141 let renamed_ssh_hosts: std::collections::HashSet<String> = manual
143 .iter()
144 .filter_map(|h| h.original_ssh_host.clone())
145 .collect();
146
147 let mut all = manual;
148 for h in ssh_hosts {
149 if !manual_names.contains(&h.name) && !renamed_ssh_hosts.contains(&h.name) {
150 all.push(h);
151 }
152 }
153 all
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn host(name: &str, source: HostSource) -> Host {
161 Host {
162 name: name.to_string(),
163 source,
164 ..Host::default()
165 }
166 }
167
168 fn renamed(name: &str, original: &str) -> Host {
170 Host {
171 name: name.to_string(),
172 source: HostSource::Manual,
173 original_ssh_host: Some(original.to_string()),
174 ..Host::default()
175 }
176 }
177
178 fn names(hosts: &[Host]) -> Vec<&str> {
179 hosts.iter().map(|h| h.name.as_str()).collect()
180 }
181
182 #[test]
185 fn merge_empty_both() {
186 assert!(merge_hosts(vec![], vec![]).is_empty());
187 }
188
189 #[test]
190 fn merge_manual_only() {
191 let out = merge_hosts(vec![host("a", HostSource::Manual)], vec![]);
192 assert_eq!(names(&out), ["a"]);
193 }
194
195 #[test]
196 fn merge_ssh_only() {
197 let out = merge_hosts(vec![], vec![host("a", HostSource::SshConfig)]);
198 assert_eq!(names(&out), ["a"]);
199 }
200
201 #[test]
202 fn merge_manual_listed_before_ssh() {
203 let out = merge_hosts(
204 vec![host("m", HostSource::Manual)],
205 vec![host("s", HostSource::SshConfig)],
206 );
207 assert_eq!(names(&out), ["m", "s"]);
208 }
209
210 #[test]
211 fn merge_name_collision_manual_wins() {
212 let out = merge_hosts(
213 vec![host("web", HostSource::Manual)],
214 vec![host("web", HostSource::SshConfig)],
215 );
216 assert_eq!(out.len(), 1);
217 assert_eq!(out[0].source, HostSource::Manual);
218 }
219
220 #[test]
221 fn merge_renamed_ssh_host_excluded() {
222 let out = merge_hosts(
223 vec![renamed("new", "old")],
224 vec![host("old", HostSource::SshConfig)],
225 );
226 assert_eq!(names(&out), ["new"]);
227 }
228
229 #[test]
230 fn merge_renamed_and_collision_combined() {
231 let out = merge_hosts(
233 vec![renamed("a", "b")],
234 vec![
235 host("a", HostSource::SshConfig),
236 host("b", HostSource::SshConfig),
237 ],
238 );
239 assert_eq!(names(&out), ["a"]);
240 }
241
242 #[test]
243 fn merge_multiple_ssh_distinct_names_kept() {
244 let out = merge_hosts(
245 vec![],
246 vec![
247 host("a", HostSource::SshConfig),
248 host("b", HostSource::SshConfig),
249 host("c", HostSource::SshConfig),
250 ],
251 );
252 assert_eq!(names(&out), ["a", "b", "c"]);
253 }
254
255 #[test]
256 fn merge_rename_keeps_unrelated_ssh_host() {
257 let out = merge_hosts(
258 vec![renamed("a", "b")],
259 vec![
260 host("b", HostSource::SshConfig),
261 host("c", HostSource::SshConfig),
262 ],
263 );
264 assert_eq!(names(&out), ["a", "c"]);
265 }
266
267 #[test]
270 fn hostsfile_roundtrip_preserves_fields() {
271 let mut h = host("web", HostSource::Manual);
272 h.hostname = "10.0.0.1".to_string();
273 h.user = "deploy".to_string();
274 h.port = 2222;
275 h.tags = vec!["prod".to_string()];
276 let toml_str = toml::to_string_pretty(&HostsFile { hosts: vec![h] }).unwrap();
277 let parsed: HostsFile = toml::from_str(&toml_str).unwrap();
278 assert_eq!(parsed.hosts.len(), 1);
279 let g = &parsed.hosts[0];
280 assert_eq!(g.name, "web");
281 assert_eq!(g.hostname, "10.0.0.1");
282 assert_eq!(g.user, "deploy");
283 assert_eq!(g.port, 2222);
284 assert_eq!(g.tags, vec!["prod"]);
285 }
286
287 #[test]
288 fn hostsfile_save_filter_keeps_only_manual() {
289 let hosts = [
291 host("m", HostSource::Manual),
292 host("s", HostSource::SshConfig),
293 ];
294 let manual: Vec<Host> = hosts
295 .iter()
296 .filter(|h| h.source == HostSource::Manual)
297 .cloned()
298 .collect();
299 assert_eq!(names(&manual), ["m"]);
300 }
301
302 #[test]
303 fn hostsfile_empty_input_parses_to_empty() {
304 let parsed: HostsFile = toml::from_str("").unwrap();
305 assert!(parsed.hosts.is_empty());
306 }
307
308 #[test]
309 fn hostsfile_omits_none_optional_fields() {
310 let toml_str = toml::to_string_pretty(&HostsFile {
311 hosts: vec![host("a", HostSource::Manual)],
312 })
313 .unwrap();
314 assert!(!toml_str.contains("identity_file"));
315 assert!(!toml_str.contains("password"));
316 }
317
318 #[test]
319 fn hostsfile_password_survives_roundtrip() {
320 let mut h = host("a", HostSource::Manual);
322 h.password = Some("secret".to_string());
323 let toml_str = toml::to_string_pretty(&HostsFile { hosts: vec![h] }).unwrap();
324 let parsed: HostsFile = toml::from_str(&toml_str).unwrap();
325 assert_eq!(parsed.hosts[0].password.as_deref(), Some("secret"));
326 }
327}