1use crate::config::Host;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5pub fn parse_ssh_config(path: &Path) -> Vec<Host> {
6 let content = match std::fs::read_to_string(path) {
7 Ok(c) => c,
8 Err(_) => return vec![],
9 };
10
11 let mut hosts = Vec::new();
12 let mut current_alias: Option<String> = None;
13 let mut hostname = String::new();
14 let mut user: Option<String> = None;
15 let mut port: u16 = 22;
16 let mut identity_file: Option<PathBuf> = None;
17
18 for line in content.lines() {
19 let trimmed = line.trim();
20
21 if trimmed.is_empty() || trimmed.starts_with('#') {
22 continue;
23 }
24
25 if let Some(rest) = trimmed.strip_prefix("Host ").or_else(|| trimmed.strip_prefix("Host\t"))
26 {
27 if let Some(alias) = current_alias.take()
28 && !hostname.is_empty()
29 && alias != "*"
30 {
31 hosts.push(Host {
32 alias,
33 hostname: hostname.clone(),
34 user: user.take(),
35 port,
36 identity_file: identity_file.take(),
37 tags: vec![],
38 notes: None,
39 tunnels: vec![],
40 commands: vec![],
41 });
42 }
43
44 let alias = rest.trim().to_string();
45 current_alias = Some(alias);
46 hostname = String::new();
47 user = None;
48 port = 22;
49 identity_file = None;
50 } else if current_alias.is_some() {
51 let (key, value) = match trimmed.split_once(char::is_whitespace) {
52 Some((k, v)) => (k.to_lowercase(), v.trim().to_string()),
53 None => continue,
54 };
55
56 match key.as_str() {
57 "hostname" => hostname = value,
58 "user" => user = Some(value),
59 "port" => port = value.parse().unwrap_or(22),
60 "identityfile" => identity_file = Some(PathBuf::from(value)),
61 _ => {}
62 }
63 }
64 }
65
66 if let Some(alias) = current_alias
67 && !hostname.is_empty()
68 && alias != "*"
69 {
70 hosts.push(Host {
71 alias,
72 hostname,
73 user,
74 port,
75 identity_file,
76 tags: vec![],
77 notes: None,
78 tunnels: vec![],
79 commands: vec![],
80 });
81 }
82
83 hosts
84}
85
86#[derive(Debug, Clone, PartialEq)]
91pub struct ParsedTunnel {
92 pub local_port: u16,
93 pub remote_host: String,
94 pub remote_port: u16,
95}
96
97#[derive(Debug, Clone, PartialEq)]
98pub struct ParsedHost {
99 pub hostname: String,
100 pub user: Option<String>,
101 pub port: u16,
102 pub tunnels: Vec<ParsedTunnel>,
103}
104
105pub fn parse_ssh_commands(text: &str) -> Vec<ParsedHost> {
110 let mut host_map: HashMap<(String, u16), ParsedHost> = HashMap::new();
111
112 for line in text.lines() {
113 let trimmed = line.trim();
114 if trimmed.is_empty() || !trimmed.contains("ssh ") && !trimmed.starts_with("ssh ") {
115 continue;
116 }
117
118 let ssh_part = if let Some(idx) = trimmed.find("ssh ") {
120 &trimmed[idx..]
121 } else {
122 continue;
123 };
124
125 if let Some(parsed) = parse_single_ssh_command(ssh_part) {
126 let key = (parsed.hostname.clone(), parsed.port);
127 let entry = host_map.entry(key).or_insert_with(|| ParsedHost {
128 hostname: parsed.hostname.clone(),
129 user: parsed.user.clone(),
130 port: parsed.port,
131 tunnels: vec![],
132 });
133 entry.tunnels.extend(parsed.tunnels);
134 }
135 }
136
137 let mut hosts: Vec<ParsedHost> = host_map.into_values().collect();
138 hosts.sort_by(|a, b| a.hostname.cmp(&b.hostname));
139 hosts
140}
141
142#[derive(Debug)]
143struct ParsedCommand {
144 hostname: String,
145 user: Option<String>,
146 port: u16,
147 tunnels: Vec<ParsedTunnel>,
148}
149
150fn parse_single_ssh_command(cmd: &str) -> Option<ParsedCommand> {
151 let tokens: Vec<&str> = cmd.split_whitespace().collect();
152 if tokens.is_empty() || tokens[0] != "ssh" {
153 return None;
154 }
155
156 let mut user: Option<String> = None;
157 let mut port: u16 = 22;
158 let mut tunnels: Vec<ParsedTunnel> = Vec::new();
159 let mut hostname: Option<String> = None;
160 let mut i = 1;
161
162 while i < tokens.len() {
163 let tok = tokens[i];
164
165 if tok == "-l" {
166 i += 1;
167 if i < tokens.len() {
168 user = Some(tokens[i].to_string());
169 }
170 } else if tok == "-p" {
171 i += 1;
172 if i < tokens.len() {
173 port = tokens[i].parse().unwrap_or(22);
174 }
175 } else if tok == "-L" {
176 i += 1;
177 if i < tokens.len()
178 && let Some(t) = parse_tunnel_spec(tokens[i])
179 {
180 tunnels.push(t);
181 }
182 } else if tok.starts_with('-') {
183 if let Some(after_l) = tok.strip_suffix('L').or_else(|| {
185 if tok.contains('L') {
187 let l_pos = tok.rfind('L')?;
189 if l_pos == tok.len() - 1 {
190 Some(&tok[..l_pos])
191 } else {
192 None
193 }
194 } else {
195 None
196 }
197 }) {
198 let _ = after_l;
199 i += 1;
200 if i < tokens.len()
201 && let Some(t) = parse_tunnel_spec(tokens[i])
202 {
203 tunnels.push(t);
204 }
205 }
206 } else if tok.contains('@') {
208 let parts: Vec<&str> = tok.splitn(2, '@').collect();
210 if parts.len() == 2 {
211 user = Some(parts[0].to_string());
212 hostname = Some(parts[1].to_string());
213 }
214 } else if tok.ends_with('&') {
215 } else {
217 hostname = Some(tok.to_string());
219 }
220
221 i += 1;
222 }
223
224 let hostname = hostname?;
225 if tunnels.is_empty() {
226 return None;
227 }
228
229 Some(ParsedCommand {
230 hostname,
231 user,
232 port,
233 tunnels,
234 })
235}
236
237fn parse_tunnel_spec(spec: &str) -> Option<ParsedTunnel> {
238 let parts: Vec<&str> = spec.splitn(3, ':').collect();
240 if parts.len() == 3 {
241 let local_port = parts[0].parse().ok()?;
242 let remote_host = parts[1].to_string();
243 let remote_port = parts[2].parse().ok()?;
244 Some(ParsedTunnel {
245 local_port,
246 remote_host,
247 remote_port,
248 })
249 } else {
250 None
251 }
252}
253
254pub fn alias_from_hostname(hostname: &str) -> String {
256 hostname
257 .split('.')
258 .next()
259 .unwrap_or(hostname)
260 .to_string()
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use std::io::Write;
267 use tempfile::NamedTempFile;
268
269 #[test]
270 fn test_parse_basic_hosts() {
271 let mut f = NamedTempFile::new().unwrap();
272 write!(
273 f,
274 r#"
275Host prod-api
276 HostName 10.0.1.50
277 User deploy
278 Port 2222
279 IdentityFile ~/.ssh/id_ed25519
280
281Host staging
282 HostName staging.example.com
283 User admin
284"#
285 )
286 .unwrap();
287
288 let hosts = parse_ssh_config(f.path());
289 assert_eq!(hosts.len(), 2);
290
291 assert_eq!(hosts[0].alias, "prod-api");
292 assert_eq!(hosts[0].hostname, "10.0.1.50");
293 assert_eq!(hosts[0].user, Some("deploy".to_string()));
294 assert_eq!(hosts[0].port, 2222);
295 assert_eq!(
296 hosts[0].identity_file,
297 Some(PathBuf::from("~/.ssh/id_ed25519"))
298 );
299
300 assert_eq!(hosts[1].alias, "staging");
301 assert_eq!(hosts[1].hostname, "staging.example.com");
302 assert_eq!(hosts[1].user, Some("admin".to_string()));
303 assert_eq!(hosts[1].port, 22);
304 }
305
306 #[test]
307 fn test_skips_wildcard_and_no_hostname() {
308 let mut f = NamedTempFile::new().unwrap();
309 write!(
310 f,
311 r#"
312Host *
313 ServerAliveInterval 60
314
315Host no-hostname
316 User test
317
318Host valid
319 HostName 1.2.3.4
320"#
321 )
322 .unwrap();
323
324 let hosts = parse_ssh_config(f.path());
325 assert_eq!(hosts.len(), 1);
326 assert_eq!(hosts[0].alias, "valid");
327 }
328
329 #[test]
330 fn test_nonexistent_file_returns_empty() {
331 let hosts = parse_ssh_config(Path::new("/tmp/nonexistent-ssh-config-12345"));
332 assert!(hosts.is_empty());
333 }
334
335 #[test]
336 fn test_parse_ssh_commands_basic() {
337 let input = r#"
338ssh -l ivan -nNTL 8881:localhost:8881 ash.onomatics.com -p 10010 &
339ssh -l ivan -nNTL 8700:localhost:8700 ash.onomatics.com -p 10010 &
340ssh -l ivan -nNTL 7213:localhost:7213 mediaservice-dev.onomatics.com -p 10010 &
341"#;
342 let hosts = parse_ssh_commands(input);
343 assert_eq!(hosts.len(), 2);
344
345 let ash = hosts.iter().find(|h| h.hostname == "ash.onomatics.com").unwrap();
346 assert_eq!(ash.port, 10010);
347 assert_eq!(ash.user, Some("ivan".to_string()));
348 assert_eq!(ash.tunnels.len(), 2);
349 assert!(ash.tunnels.iter().any(|t| t.local_port == 8881));
350 assert!(ash.tunnels.iter().any(|t| t.local_port == 8700));
351
352 let media = hosts.iter().find(|h| h.hostname == "mediaservice-dev.onomatics.com").unwrap();
353 assert_eq!(media.tunnels.len(), 1);
354 assert_eq!(media.tunnels[0].local_port, 7213);
355 }
356
357 #[test]
358 fn test_parse_ssh_commands_user_at_host() {
359 let input = "ssh -NL 5432:localhost:5432 deploy@db.example.com";
360 let hosts = parse_ssh_commands(input);
361 assert_eq!(hosts.len(), 1);
362 assert_eq!(hosts[0].hostname, "db.example.com");
363 assert_eq!(hosts[0].user, Some("deploy".to_string()));
364 assert_eq!(hosts[0].tunnels[0].local_port, 5432);
365 }
366
367 #[test]
368 fn test_parse_ssh_commands_skips_non_tunnel() {
369 let input = r#"
370echo "Starting tunnels"
371ssh user@host.com
372ssh -l ivan -nNTL 8881:localhost:8881 ash.com -p 10010
373echo "done"
374"#;
375 let hosts = parse_ssh_commands(input);
376 assert_eq!(hosts.len(), 1);
377 assert_eq!(hosts[0].hostname, "ash.com");
378 }
379
380 #[test]
381 fn test_alias_from_hostname() {
382 assert_eq!(alias_from_hostname("ash.onomatics.com"), "ash");
383 assert_eq!(alias_from_hostname("10.0.1.50"), "10");
384 assert_eq!(alias_from_hostname("myhost"), "myhost");
385 }
386}