1use std::path::PathBuf;
15
16pub fn home_from_env(get: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
20 let nonempty = |k: &str| get(k).filter(|v| !v.is_empty());
21 if let Some(h) = nonempty("HOME") {
22 return Some(PathBuf::from(h));
23 }
24 if let Some(up) = nonempty("USERPROFILE") {
25 return Some(PathBuf::from(up));
26 }
27 if let (Some(d), Some(p)) = (nonempty("HOMEDRIVE"), nonempty("HOMEPATH")) {
28 return Some(PathBuf::from(format!("{d}{p}")));
29 }
30 None
31}
32
33pub fn clipboard_candidates(os: &str) -> Vec<(&'static str, Vec<&'static str>)> {
38 match os {
39 "macos" => vec![("pbcopy", vec![])],
40 "windows" => vec![("clip", vec![])],
41 _ => vec![
43 ("wl-copy", vec![]),
44 ("xclip", vec!["-selection", "clipboard"]),
45 ("xsel", vec!["--clipboard", "--input"]),
46 ],
47 }
48}
49
50pub const PROVIDERS: &[&str] = &["github.com", "bitbucket.org", "gitlab.com"];
54
55#[derive(Debug, PartialEq, Eq)]
57pub enum ProviderChoice {
58 Host(String),
61 Custom,
63 Invalid,
65}
66
67pub fn provider_choice(raw: &str) -> ProviderChoice {
72 let t = raw.trim();
73 if t.is_empty() {
74 return ProviderChoice::Host(PROVIDERS[0].to_string());
75 }
76 if let Ok(n) = t.parse::<usize>() {
77 if (1..=PROVIDERS.len()).contains(&n) {
78 return ProviderChoice::Host(PROVIDERS[n - 1].to_string());
79 }
80 if n == PROVIDERS.len() + 1 {
81 return ProviderChoice::Custom;
82 }
83 return ProviderChoice::Invalid;
84 }
85 ProviderChoice::Host(t.to_string())
86}
87
88pub fn host_block(alias: &str, hostname: &str, port: Option<u16>, macos: bool) -> String {
90 let mut s = String::new();
91 s.push_str(&format!("Host {alias}\n"));
92 s.push_str(&format!(" HostName {hostname}\n"));
93 s.push_str(" User git\n");
94 s.push_str(" AddKeysToAgent yes\n");
95 if macos {
96 s.push_str(" UseKeychain yes\n");
97 }
98 s.push_str(" IdentitiesOnly yes\n");
99 s.push_str(&format!(" IdentityFile ~/.ssh/id_{alias}\n"));
100 if let Some(p) = port {
101 s.push_str(&format!(" Port {p}\n"));
102 }
103 s
104}
105
106pub fn upsert_block(existing: &str, alias: &str, block: &str) -> String {
109 let kept = remove_host_block(existing, alias);
110 let mut out = kept.trim_end().to_string();
111 if !out.is_empty() {
112 out.push_str("\n\n");
113 }
114 out.push_str(block.trim_end());
115 out.push('\n');
116 out
117}
118
119fn remove_host_block(content: &str, alias: &str) -> String {
122 let mut out = String::new();
123 let mut skipping = false;
124 for line in content.lines() {
125 if let Some(rest) = line.trim_start().strip_prefix("Host ") {
126 skipping = rest.split_whitespace().next() == Some(alias);
127 }
128 if !skipping {
129 out.push_str(line);
130 out.push('\n');
131 }
132 }
133 out
134}
135
136pub fn ensure_include(ssh_config: &str) -> Option<String> {
139 if ssh_config.lines().any(|l| l.trim() == "Include git_users") {
140 return None;
141 }
142 let mut s = String::from("Include git_users\n");
143 if !ssh_config.trim().is_empty() {
144 s.push('\n');
145 s.push_str(ssh_config);
146 if !ssh_config.ends_with('\n') {
147 s.push('\n');
148 }
149 }
150 Some(s)
151}
152
153pub fn list_hosts(git_users: &str) -> Vec<(String, String)> {
155 let mut hosts: Vec<(String, String)> = Vec::new();
156 for line in git_users.lines() {
157 let t = line.trim_start();
158 if let Some(rest) = t.strip_prefix("Host ") {
159 if let Some(a) = rest.split_whitespace().next() {
160 hosts.push((a.to_string(), String::new()));
161 }
162 } else if let Some(idf) = t.strip_prefix("IdentityFile ") {
163 if let Some(last) = hosts.last_mut() {
164 last.1 = idf.trim().to_string();
165 }
166 }
167 }
168 hosts
169}
170
171pub fn hostname_for(git_users: &str, alias: &str) -> Option<String> {
177 let mut in_block = false;
178 for line in git_users.lines() {
179 let t = line.trim_start();
180 if let Some(rest) = t.strip_prefix("Host ") {
181 in_block = rest.split_whitespace().next() == Some(alias);
182 } else if in_block {
183 if let Some(h) = t.strip_prefix("HostName ") {
184 return Some(h.trim().to_string());
185 }
186 }
187 }
188 None
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn provider_menu_maps_input() {
197 assert_eq!(
199 provider_choice(""),
200 ProviderChoice::Host("github.com".into())
201 );
202 assert_eq!(
203 provider_choice(" "),
204 ProviderChoice::Host("github.com".into())
205 );
206 assert_eq!(
208 provider_choice("1"),
209 ProviderChoice::Host("github.com".into())
210 );
211 assert_eq!(
212 provider_choice("2"),
213 ProviderChoice::Host("bitbucket.org".into())
214 );
215 assert_eq!(
216 provider_choice("3"),
217 ProviderChoice::Host("gitlab.com".into())
218 );
219 assert_eq!(provider_choice("4"), ProviderChoice::Custom);
221 assert_eq!(provider_choice("9"), ProviderChoice::Invalid);
223 assert_eq!(provider_choice("0"), ProviderChoice::Invalid);
224 assert_eq!(
226 provider_choice("git.mycorp.com"),
227 ProviderChoice::Host("git.mycorp.com".into())
228 );
229 }
230
231 #[test]
232 fn block_is_os_aware() {
233 let mac = host_block("acme", "github.com", None, true);
234 assert!(mac.contains("Host acme"));
235 assert!(mac.contains("IdentityFile ~/.ssh/id_acme"));
236 assert!(mac.contains("UseKeychain yes"));
237 let linux = host_block("acme", "github.com", None, false);
238 assert!(!linux.contains("UseKeychain"));
239 }
240
241 #[test]
242 fn block_includes_port_when_set() {
243 assert!(host_block("a", "h", Some(2222), false).contains("Port 2222"));
244 assert!(!host_block("a", "h", None, false).contains("Port"));
245 }
246
247 #[test]
248 fn upsert_replaces_existing_alias_keeps_others() {
249 let existing = "Include project_config\n\nHost acme\n HostName old\n IdentityFile ~/.ssh/id_acme\n\nHost other\n HostName github.com\n";
250 let new_block = host_block("acme", "github.com", None, true);
251 let out = upsert_block(existing, "acme", &new_block);
252 assert_eq!(
253 out.matches("Host acme").count(),
254 1,
255 "exactly one acme block:\n{out}"
256 );
257 assert!(out.contains("HostName github.com")); assert!(!out.contains("HostName old")); assert!(out.contains("Host other")); assert!(out.contains("Include project_config")); }
262
263 #[test]
264 fn ensure_include_adds_only_when_missing() {
265 assert!(ensure_include("Host x\n")
266 .unwrap()
267 .starts_with("Include git_users"));
268 assert_eq!(ensure_include("Include git_users\nHost x\n"), None);
269 }
270
271 fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
272 let m: std::collections::HashMap<String, String> = pairs
273 .iter()
274 .map(|(k, v)| (k.to_string(), v.to_string()))
275 .collect();
276 move |k: &str| m.get(k).cloned()
277 }
278
279 #[test]
280 fn home_resolves_home_then_userprofile_then_homedrive() {
281 assert_eq!(
283 home_from_env(env_of(&[
284 ("HOME", "/home/u"),
285 ("USERPROFILE", "C:\\Users\\u")
286 ])),
287 Some(PathBuf::from("/home/u"))
288 );
289 assert_eq!(
291 home_from_env(env_of(&[("USERPROFILE", "C:\\Users\\u")])),
292 Some(PathBuf::from("C:\\Users\\u"))
293 );
294 assert_eq!(
296 home_from_env(env_of(&[("HOME", ""), ("USERPROFILE", "C:\\Users\\u")])),
297 Some(PathBuf::from("C:\\Users\\u"))
298 );
299 assert_eq!(
301 home_from_env(env_of(&[("HOMEDRIVE", "C:"), ("HOMEPATH", "\\Users\\u")])),
302 Some(PathBuf::from("C:\\Users\\u"))
303 );
304 assert_eq!(home_from_env(env_of(&[])), None);
306 }
307
308 #[test]
309 fn clipboard_candidates_are_os_specific() {
310 let names = |os| {
311 clipboard_candidates(os)
312 .into_iter()
313 .map(|(p, _)| p)
314 .collect::<Vec<_>>()
315 };
316 assert_eq!(names("macos"), vec!["pbcopy"]);
317 assert_eq!(names("windows"), vec!["clip"]);
318 assert_eq!(names("linux"), vec!["wl-copy", "xclip", "xsel"]);
319 }
320
321 #[test]
322 fn lists_hosts_with_identity() {
323 let g =
324 "Host acme\n IdentityFile ~/.ssh/id_acme\nHost work\n IdentityFile ~/.ssh/id_work\n";
325 assert_eq!(
326 list_hosts(g),
327 vec![
328 ("acme".into(), "~/.ssh/id_acme".into()),
329 ("work".into(), "~/.ssh/id_work".into())
330 ]
331 );
332 }
333
334 #[test]
335 fn hostname_for_resolves_per_block() {
336 let g = "Host ltlgh\n HostName github.com\n IdentityFile ~/.ssh/id_ltlgh\n\
339 Host tlbb\n HostName bitbucket.org\n IdentityFile ~/.ssh/id_tlbb\n";
340 assert_eq!(hostname_for(g, "ltlgh").as_deref(), Some("github.com"));
341 assert_eq!(hostname_for(g, "tlbb").as_deref(), Some("bitbucket.org"));
342 assert_eq!(hostname_for(g, "nope"), None);
344 assert_eq!(
345 hostname_for("Host x\n IdentityFile ~/.ssh/id_x\n", "x"),
346 None
347 );
348 }
349}