Skip to main content

omnyssh_core/config/
mod.rs

1//! Application configuration modules.
2//!
3//! - [`app_config`]  — main `~/.config/omnyssh/config.toml`
4//! - [`ssh_config`]  — parser for `~/.ssh/config`
5//! - [`snippets`]    — `~/.config/omnyssh/snippets.toml`
6//!
7//! Top-level functions in this module handle loading and persisting the
8//! host list (`hosts.toml`).
9
10pub 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// ---------------------------------------------------------------------------
21// hosts.toml I/O
22// ---------------------------------------------------------------------------
23
24/// TOML container for the hosts list.
25#[derive(Debug, Default, Serialize, Deserialize)]
26struct HostsFile {
27    #[serde(default)]
28    hosts: Vec<Host>,
29}
30
31/// Loads manually-added hosts from `~/.config/omnyssh/hosts.toml`.
32///
33/// Returns an empty `Vec` if the file does not exist yet.
34///
35/// # Errors
36/// Returns an error if the file exists but cannot be read or parsed.
37pub 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
53/// Persists the manually-added hosts (source == `Manual`) to
54/// `~/.config/omnyssh/hosts.toml`.
55///
56/// SSH-config-derived hosts are intentionally **not** written — they are
57/// re-imported from `~/.ssh/config` on every startup.
58///
59/// # Errors
60/// Returns an error if the directory cannot be created or the file cannot
61/// be written.
62pub 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    // Write to a temp file and rename for atomic replacement (avoids a corrupt
80    // hosts.toml if the process is interrupted mid-write).
81    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
104/// Loads all hosts: manually-added (`hosts.toml`) merged with hosts
105/// imported from `~/.ssh/config`.
106///
107/// Manual entries take priority over SSH-config entries with the same name.
108/// SSH-config entries are appended after all manual ones.
109///
110/// # Errors
111/// Returns an error if `hosts.toml` exists but is unreadable/malformed.
112/// A missing or unreadable `~/.ssh/config` is silently ignored.
113pub fn load_all_hosts() -> anyhow::Result<Vec<Host>> {
114    // 1. Manual hosts (from hosts.toml).
115    let manual = load_hosts()?;
116
117    // 2. Hosts from ~/.ssh/config.
118    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    // 3. Merge: manual names take priority.
129    Ok(merge_hosts(manual, ssh_hosts))
130}
131
132/// Merges manually-added hosts with hosts imported from `~/.ssh/config`.
133///
134/// Manual entries come first and take priority: an SSH-config host is dropped
135/// when a manual host already uses its name, or when a manual host records it
136/// as a renamed original (via `original_ssh_host`).
137pub(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    // Original SSH-config names of hosts that have since been renamed.
142    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    /// A manual host renamed from an SSH-config entry named `original`.
169    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    // --- merge_hosts (P0.3) -----------------------------------------------
183
184    #[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        // Manual "a" was renamed from ssh "b"; ssh has both "a" and "b".
232        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    // --- HostsFile serialization (P0.4) -----------------------------------
268
269    #[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        // Replicates the `source == Manual` filter applied by save_hosts.
290        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        // Confirms passwords are persisted in plaintext.
321        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}