1use super::types::{SshHost, SshHostSource};
4use std::collections::HashSet;
5use std::path::Path;
6
7pub fn scan_history() -> Vec<SshHost> {
9 let mut hosts = Vec::new();
10 let mut seen = HashSet::new();
11
12 if let Some(home) = dirs::home_dir() {
13 let bash_hist = home.join(".bash_history");
14 if bash_hist.exists() {
15 scan_history_file(&bash_hist, false, &mut hosts, &mut seen);
16 }
17
18 let zsh_hist = home.join(".zsh_history");
19 if zsh_hist.exists() {
20 scan_history_file(&zsh_hist, true, &mut hosts, &mut seen);
21 }
22
23 let fish_hist = home.join(".local/share/fish/fish_history");
24 if fish_hist.exists() {
25 scan_fish_history(&fish_hist, &mut hosts, &mut seen);
26 }
27 }
28
29 hosts
30}
31
32fn scan_history_file(
33 path: &Path,
34 is_zsh: bool,
35 hosts: &mut Vec<SshHost>,
36 seen: &mut HashSet<String>,
37) {
38 let content = match std::fs::read_to_string(path) {
39 Ok(c) => c,
40 Err(_) => return,
41 };
42
43 for line in content.lines() {
44 let line = if is_zsh {
45 line.split_once(';').map(|(_, cmd)| cmd).unwrap_or(line)
46 } else {
47 line
48 };
49
50 if let Some(host) = parse_ssh_command(line) {
51 let key = host.connection_string();
52 if !seen.contains(&key) {
53 seen.insert(key);
54 hosts.push(host);
55 }
56 }
57 }
58}
59
60fn scan_fish_history(path: &Path, hosts: &mut Vec<SshHost>, seen: &mut HashSet<String>) {
61 let content = match std::fs::read_to_string(path) {
62 Ok(c) => c,
63 Err(_) => return,
64 };
65
66 for line in content.lines() {
67 let line = line.trim();
68 if let Some(cmd) = line.strip_prefix("- cmd: ")
69 && let Some(host) = parse_ssh_command(cmd)
70 {
71 let key = host.connection_string();
72 if !seen.contains(&key) {
73 seen.insert(key);
74 hosts.push(host);
75 }
76 }
77 }
78}
79
80pub fn parse_ssh_command(line: &str) -> Option<SshHost> {
82 let parts: Vec<&str> = line.split_whitespace().collect();
83
84 let ssh_idx = parts.iter().position(|&p| p == "ssh")?;
85 let args = &parts[ssh_idx + 1..];
86
87 if args.is_empty() {
88 return None;
89 }
90
91 let mut port: Option<u16> = None;
92 let mut identity: Option<String> = None;
93 let mut target: Option<&str> = None;
94 let mut i = 0;
95
96 while i < args.len() {
97 match args[i] {
98 "-p" => {
99 if i + 1 < args.len() {
100 port = args[i + 1].parse().ok();
101 i += 2;
102 } else {
103 i += 1;
104 }
105 }
106 "-i" => {
107 if i + 1 < args.len() {
108 identity = Some(args[i + 1].to_string());
109 i += 2;
110 } else {
111 i += 1;
112 }
113 }
114 "-J" => {
115 i += 2;
116 }
117 arg if arg.starts_with('-') => {
118 let takes_value = matches!(
119 arg,
120 "-b" | "-c"
121 | "-D"
122 | "-E"
123 | "-e"
124 | "-F"
125 | "-I"
126 | "-L"
127 | "-l"
128 | "-m"
129 | "-O"
130 | "-o"
131 | "-Q"
132 | "-R"
133 | "-S"
134 | "-W"
135 | "-w"
136 );
137 if takes_value && i + 1 < args.len() {
138 i += 2;
139 } else {
140 i += 1;
141 }
142 }
143 arg => {
144 if target.is_none() {
145 target = Some(arg);
146 }
147 i += 1;
148 }
149 }
150 }
151
152 let target = target?;
153
154 if target.starts_with('/') || target.starts_with('.') || target.contains('=') {
155 return None;
156 }
157
158 let (user, hostname) = if let Some((u, h)) = target.split_once('@') {
159 (Some(u.to_string()), h.to_string())
160 } else {
161 (None, target.to_string())
162 };
163
164 if hostname.is_empty() || hostname.starts_with('-') {
165 return None;
166 }
167
168 Some(SshHost {
169 alias: hostname.clone(),
170 hostname: Some(hostname),
171 user,
172 port,
173 identity_file: identity,
174 proxy_jump: None,
175 source: SshHostSource::History,
176 })
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn test_parse_simple_ssh() {
185 let host = parse_ssh_command("ssh myhost.com").unwrap();
186 assert_eq!(host.alias, "myhost.com");
187 assert_eq!(host.user, None);
188 assert_eq!(host.port, None);
189 }
190
191 #[test]
192 fn test_parse_user_at_host() {
193 let host = parse_ssh_command("ssh deploy@myhost.com").unwrap();
194 assert_eq!(host.alias, "myhost.com");
195 assert_eq!(host.user.as_deref(), Some("deploy"));
196 }
197
198 #[test]
199 fn test_parse_with_port() {
200 let host = parse_ssh_command("ssh -p 2222 myhost.com").unwrap();
201 assert_eq!(host.port, Some(2222));
202 }
203
204 #[test]
205 fn test_parse_with_identity() {
206 let host = parse_ssh_command("ssh -i ~/.ssh/id_work myhost.com").unwrap();
207 assert_eq!(host.identity_file.as_deref(), Some("~/.ssh/id_work"));
208 }
209
210 #[test]
211 fn test_parse_complex_command() {
212 let host =
213 parse_ssh_command("ssh -p 2222 -i ~/.ssh/key deploy@server.example.com").unwrap();
214 assert_eq!(host.alias, "server.example.com");
215 assert_eq!(host.user.as_deref(), Some("deploy"));
216 assert_eq!(host.port, Some(2222));
217 assert_eq!(host.identity_file.as_deref(), Some("~/.ssh/key"));
218 }
219
220 #[test]
221 fn test_skip_non_ssh() {
222 assert!(parse_ssh_command("ls -la").is_none());
223 assert!(parse_ssh_command("git push").is_none());
224 }
225
226 #[test]
227 fn test_skip_ssh_only() {
228 assert!(parse_ssh_command("ssh").is_none());
229 }
230
231 #[test]
232 fn test_ssh_with_preceding_command() {
233 let host = parse_ssh_command("TERM=xterm ssh myhost.com").unwrap();
234 assert_eq!(host.alias, "myhost.com");
235 }
236}