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