Skip to main content

zoi/
command.rs

1use std::{fs, path::{Path, PathBuf}};
2
3use crate::parser;
4
5pub fn get_path() -> String {
6    resolve_config_path(home::home_dir())
7}
8
9/// Resolves the `~/.ssh/config` path from a given home directory, creating an
10/// empty config file if it does not exist yet. Split out from `get_path` so the
11/// home-resolution branches can be unit tested without touching `$HOME`.
12fn resolve_config_path(home: Option<PathBuf>) -> String {
13    let mut path = match home {
14        Some(p) => p.to_str().unwrap().to_string(),
15        None => {
16            println!("Impossible to get your home dir!");
17            String::new()
18        }
19    };
20    path.push_str("/.ssh/config");
21    if !Path::new(&path).exists() {
22        reset_config_file(&path);
23    }
24    path
25}
26
27fn reset_config_file(path: &str) {
28    match fs::write(path, "") {
29        Ok(_) => println!("Recreated config file!"),
30        Err(_) => ()
31    }
32}
33
34pub fn delete_all_command() {
35    let path = get_path();
36    reset_config_file(&path);
37}
38
39fn extract_host_name(host_item: &mut parser::HostItem, host_string: String) {
40    let mut user = String::from("root");
41    let mut hostname = String::from("");
42    let mut port = String::from("22");
43
44    let splitted: Vec<String> = host_string.split("@")
45        .map(|s| s.to_string())
46        .collect();
47
48    if splitted.len() == 1 {
49        hostname = String::from(&splitted[0]);
50    } else if splitted.len() == 2 {
51        user = String::from(&splitted[0]);
52        hostname = String::from(&splitted[1]);
53    }
54
55    let hostname_splitted: Vec<String> = hostname.split(":")
56        .map(|s| s.to_string())
57        .collect();
58
59    if hostname_splitted.len() == 2 {
60        hostname = String::from(&hostname_splitted[0]);
61        port = String::from(&hostname_splitted[1]);
62    }
63
64    host_item.user = user;
65    host_item.host = hostname;
66    host_item.port = port;
67}
68
69pub fn add_command(name: &String, host: &String, private_key: &Option<String>, port: &Option<String>) {
70    let path = get_path();
71
72    let mut new_host = parser::HostItem::new();
73    new_host.name = name.clone();
74    extract_host_name(&mut new_host, host.to_string());
75    if let Some(key) = private_key {
76        new_host.identity_file = key.clone();
77    }
78    // An explicit --port flag wins over any port embedded in the value string.
79    if let Some(p) = port {
80        new_host.port = p.clone();
81    }
82    let mut config = parser::parse(&path);
83    config.hosts.push(new_host);
84    config.write(&path).unwrap();
85}
86
87pub fn edit_command(name: &String, host: &String, private_key: &Option<String>, port: &Option<String>) {
88    let path = get_path();
89    let mut config = parser::parse(&path);
90    let mut host_item = parser::HostItem::new();
91
92    extract_host_name(&mut host_item, host.to_string());
93    if let Some(key) = private_key {
94        host_item.identity_file = key.clone();
95    }
96    // An explicit --port flag wins over any port embedded in the value string.
97    if let Some(p) = port {
98        host_item.port = p.clone();
99    }
100
101    match config.edit(name, &host_item) {
102        Ok(_) => config.write(&path).unwrap(),
103        Err(err) => eprintln!("Can not edit: {}", err)
104    }
105}
106
107pub fn rename_command(name: &String, host: &String) {
108    let path = get_path();
109    let mut config = parser::parse(&path);
110    match config.rename(name, host) {
111        Ok(_) => config.write(&path).unwrap(),
112        Err(err) => eprintln!("Can not rename: {}", err)
113    }
114}
115
116pub fn delete_command(name: &String) {
117    let path = get_path();
118    let mut config = parser::parse(&path);
119    match config.delete(name) {
120        Ok(_) => config.write(&path).unwrap(),
121        Err(err) => eprintln!("Can not delete: {}", err)
122    }
123}
124
125pub fn list_command() {
126    let path = get_path();
127    let config = parser::parse(&path);
128
129    if config.hosts.len() == 0 {
130        println!("No records");
131    } else {
132        config.log()
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::parser::HostItem;
140    use std::path::PathBuf;
141    use std::sync::atomic::{AtomicUsize, Ordering};
142
143    fn unique_tmp(tag: &str) -> PathBuf {
144        static COUNTER: AtomicUsize = AtomicUsize::new(0);
145        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
146        let mut dir = std::env::temp_dir();
147        dir.push(format!("zoi_cmd_{}_{}_{}", tag, std::process::id(), n));
148        dir
149    }
150
151    #[test]
152    fn extract_hostname_only_uses_defaults() {
153        let mut host = HostItem::new();
154        extract_host_name(&mut host, "example.com".to_string());
155        assert_eq!(host.user, "root");
156        assert_eq!(host.host, "example.com");
157        assert_eq!(host.port, "22");
158    }
159
160    #[test]
161    fn extract_user_host_and_port() {
162        let mut host = HostItem::new();
163        extract_host_name(&mut host, "deploy@10.0.0.5:2222".to_string());
164        assert_eq!(host.user, "deploy");
165        assert_eq!(host.host, "10.0.0.5");
166        assert_eq!(host.port, "2222");
167    }
168
169    #[test]
170    fn extract_user_host_without_port_defaults_22() {
171        let mut host = HostItem::new();
172        extract_host_name(&mut host, "admin@myhost".to_string());
173        assert_eq!(host.user, "admin");
174        assert_eq!(host.host, "myhost");
175        assert_eq!(host.port, "22");
176    }
177
178    #[test]
179    fn extract_malformed_multiple_at_leaves_host_empty() {
180        // "a@b@c" splits into 3 parts: neither the len==1 nor len==2 arm runs,
181        // so hostname stays empty and the defaults are kept.
182        let mut host = HostItem::new();
183        extract_host_name(&mut host, "a@b@c".to_string());
184        assert_eq!(host.user, "root");
185        assert_eq!(host.host, "");
186        assert_eq!(host.port, "22");
187    }
188
189    #[test]
190    fn resolve_path_creates_config_when_missing() {
191        let dir = unique_tmp("missing");
192        std::fs::create_dir_all(dir.join(".ssh")).unwrap();
193
194        let path = resolve_config_path(Some(dir.clone()));
195
196        assert_eq!(path, format!("{}/.ssh/config", dir.to_str().unwrap()));
197        assert!(Path::new(&path).exists());
198        assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
199
200        std::fs::remove_dir_all(&dir).ok();
201    }
202
203    #[test]
204    fn resolve_path_keeps_existing_config() {
205        let dir = unique_tmp("existing");
206        let ssh = dir.join(".ssh");
207        std::fs::create_dir_all(&ssh).unwrap();
208        std::fs::write(ssh.join("config"), "Host x\nhostname y\nuser z\nport 1\n").unwrap();
209
210        let path = resolve_config_path(Some(dir.clone()));
211
212        // Existing content must be preserved (reset must NOT run).
213        assert!(std::fs::read_to_string(&path).unwrap().contains("Host x"));
214
215        std::fs::remove_dir_all(&dir).ok();
216    }
217
218    #[test]
219    fn resolve_path_without_home_does_not_panic() {
220        // Exercises the `None` arm; writing to /.ssh/config fails silently so
221        // this must return the fallback path without panicking.
222        let path = resolve_config_path(None);
223        assert_eq!(path, "/.ssh/config");
224    }
225
226    #[test]
227    fn reset_config_file_truncates_and_ignores_bad_path() {
228        let dir = unique_tmp("reset");
229        std::fs::create_dir_all(&dir).unwrap();
230        let good = dir.join("cfg");
231        std::fs::write(&good, "some content").unwrap();
232
233        reset_config_file(good.to_str().unwrap());
234        assert_eq!(std::fs::read_to_string(&good).unwrap(), "");
235
236        // Err branch: parent directory does not exist -> silently ignored.
237        reset_config_file("/zoi_nonexistent_dir_xyz/cfg");
238
239        std::fs::remove_dir_all(&dir).ok();
240    }
241}