use std::path::PathBuf;
pub fn home_from_env(get: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
let nonempty = |k: &str| get(k).filter(|v| !v.is_empty());
if let Some(h) = nonempty("HOME") {
return Some(PathBuf::from(h));
}
if let Some(up) = nonempty("USERPROFILE") {
return Some(PathBuf::from(up));
}
if let (Some(d), Some(p)) = (nonempty("HOMEDRIVE"), nonempty("HOMEPATH")) {
return Some(PathBuf::from(format!("{d}{p}")));
}
None
}
pub fn clipboard_candidates(os: &str) -> Vec<(&'static str, Vec<&'static str>)> {
match os {
"macos" => vec![("pbcopy", vec![])],
"windows" => vec![("clip", vec![])],
_ => vec![
("wl-copy", vec![]),
("xclip", vec!["-selection", "clipboard"]),
("xsel", vec!["--clipboard", "--input"]),
],
}
}
pub const PROVIDERS: &[&str] = &["github.com", "bitbucket.org", "gitlab.com"];
#[derive(Debug, PartialEq, Eq)]
pub enum ProviderChoice {
Host(String),
Custom,
Invalid,
}
pub fn provider_choice(raw: &str) -> ProviderChoice {
let t = raw.trim();
if t.is_empty() {
return ProviderChoice::Host(PROVIDERS[0].to_string());
}
if let Ok(n) = t.parse::<usize>() {
if (1..=PROVIDERS.len()).contains(&n) {
return ProviderChoice::Host(PROVIDERS[n - 1].to_string());
}
if n == PROVIDERS.len() + 1 {
return ProviderChoice::Custom;
}
return ProviderChoice::Invalid;
}
ProviderChoice::Host(t.to_string())
}
pub fn host_block(alias: &str, hostname: &str, port: Option<u16>, macos: bool) -> String {
let mut s = String::new();
s.push_str(&format!("Host {alias}\n"));
s.push_str(&format!(" HostName {hostname}\n"));
s.push_str(" User git\n");
s.push_str(" AddKeysToAgent yes\n");
if macos {
s.push_str(" UseKeychain yes\n");
}
s.push_str(" IdentitiesOnly yes\n");
s.push_str(&format!(" IdentityFile ~/.ssh/id_{alias}\n"));
if let Some(p) = port {
s.push_str(&format!(" Port {p}\n"));
}
s
}
pub fn upsert_block(existing: &str, alias: &str, block: &str) -> String {
let kept = remove_host_block(existing, alias);
let mut out = kept.trim_end().to_string();
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(block.trim_end());
out.push('\n');
out
}
fn remove_host_block(content: &str, alias: &str) -> String {
let mut out = String::new();
let mut skipping = false;
for line in content.lines() {
if let Some(rest) = line.trim_start().strip_prefix("Host ") {
skipping = rest.split_whitespace().next() == Some(alias);
}
if !skipping {
out.push_str(line);
out.push('\n');
}
}
out
}
pub fn ensure_include(ssh_config: &str) -> Option<String> {
if ssh_config.lines().any(|l| l.trim() == "Include git_users") {
return None;
}
let mut s = String::from("Include git_users\n");
if !ssh_config.trim().is_empty() {
s.push('\n');
s.push_str(ssh_config);
if !ssh_config.ends_with('\n') {
s.push('\n');
}
}
Some(s)
}
pub fn list_hosts(git_users: &str) -> Vec<(String, String)> {
let mut hosts: Vec<(String, String)> = Vec::new();
for line in git_users.lines() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("Host ") {
if let Some(a) = rest.split_whitespace().next() {
hosts.push((a.to_string(), String::new()));
}
} else if let Some(idf) = t.strip_prefix("IdentityFile ") {
if let Some(last) = hosts.last_mut() {
last.1 = idf.trim().to_string();
}
}
}
hosts
}
pub fn hostname_for(git_users: &str, alias: &str) -> Option<String> {
let mut in_block = false;
for line in git_users.lines() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("Host ") {
in_block = rest.split_whitespace().next() == Some(alias);
} else if in_block {
if let Some(h) = t.strip_prefix("HostName ") {
return Some(h.trim().to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_menu_maps_input() {
assert_eq!(
provider_choice(""),
ProviderChoice::Host("github.com".into())
);
assert_eq!(
provider_choice(" "),
ProviderChoice::Host("github.com".into())
);
assert_eq!(
provider_choice("1"),
ProviderChoice::Host("github.com".into())
);
assert_eq!(
provider_choice("2"),
ProviderChoice::Host("bitbucket.org".into())
);
assert_eq!(
provider_choice("3"),
ProviderChoice::Host("gitlab.com".into())
);
assert_eq!(provider_choice("4"), ProviderChoice::Custom);
assert_eq!(provider_choice("9"), ProviderChoice::Invalid);
assert_eq!(provider_choice("0"), ProviderChoice::Invalid);
assert_eq!(
provider_choice("git.mycorp.com"),
ProviderChoice::Host("git.mycorp.com".into())
);
}
#[test]
fn block_is_os_aware() {
let mac = host_block("acme", "github.com", None, true);
assert!(mac.contains("Host acme"));
assert!(mac.contains("IdentityFile ~/.ssh/id_acme"));
assert!(mac.contains("UseKeychain yes"));
let linux = host_block("acme", "github.com", None, false);
assert!(!linux.contains("UseKeychain"));
}
#[test]
fn block_includes_port_when_set() {
assert!(host_block("a", "h", Some(2222), false).contains("Port 2222"));
assert!(!host_block("a", "h", None, false).contains("Port"));
}
#[test]
fn upsert_replaces_existing_alias_keeps_others() {
let existing = "Include project_config\n\nHost acme\n HostName old\n IdentityFile ~/.ssh/id_acme\n\nHost other\n HostName github.com\n";
let new_block = host_block("acme", "github.com", None, true);
let out = upsert_block(existing, "acme", &new_block);
assert_eq!(
out.matches("Host acme").count(),
1,
"exactly one acme block:\n{out}"
);
assert!(out.contains("HostName github.com")); assert!(!out.contains("HostName old")); assert!(out.contains("Host other")); assert!(out.contains("Include project_config")); }
#[test]
fn ensure_include_adds_only_when_missing() {
assert!(ensure_include("Host x\n")
.unwrap()
.starts_with("Include git_users"));
assert_eq!(ensure_include("Include git_users\nHost x\n"), None);
}
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let m: std::collections::HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |k: &str| m.get(k).cloned()
}
#[test]
fn home_resolves_home_then_userprofile_then_homedrive() {
assert_eq!(
home_from_env(env_of(&[
("HOME", "/home/u"),
("USERPROFILE", "C:\\Users\\u")
])),
Some(PathBuf::from("/home/u"))
);
assert_eq!(
home_from_env(env_of(&[("USERPROFILE", "C:\\Users\\u")])),
Some(PathBuf::from("C:\\Users\\u"))
);
assert_eq!(
home_from_env(env_of(&[("HOME", ""), ("USERPROFILE", "C:\\Users\\u")])),
Some(PathBuf::from("C:\\Users\\u"))
);
assert_eq!(
home_from_env(env_of(&[("HOMEDRIVE", "C:"), ("HOMEPATH", "\\Users\\u")])),
Some(PathBuf::from("C:\\Users\\u"))
);
assert_eq!(home_from_env(env_of(&[])), None);
}
#[test]
fn clipboard_candidates_are_os_specific() {
let names = |os| {
clipboard_candidates(os)
.into_iter()
.map(|(p, _)| p)
.collect::<Vec<_>>()
};
assert_eq!(names("macos"), vec!["pbcopy"]);
assert_eq!(names("windows"), vec!["clip"]);
assert_eq!(names("linux"), vec!["wl-copy", "xclip", "xsel"]);
}
#[test]
fn lists_hosts_with_identity() {
let g =
"Host acme\n IdentityFile ~/.ssh/id_acme\nHost work\n IdentityFile ~/.ssh/id_work\n";
assert_eq!(
list_hosts(g),
vec![
("acme".into(), "~/.ssh/id_acme".into()),
("work".into(), "~/.ssh/id_work".into())
]
);
}
#[test]
fn hostname_for_resolves_per_block() {
let g = "Host ltlgh\n HostName github.com\n IdentityFile ~/.ssh/id_ltlgh\n\
Host tlbb\n HostName bitbucket.org\n IdentityFile ~/.ssh/id_tlbb\n";
assert_eq!(hostname_for(g, "ltlgh").as_deref(), Some("github.com"));
assert_eq!(hostname_for(g, "tlbb").as_deref(), Some("bitbucket.org"));
assert_eq!(hostname_for(g, "nope"), None);
assert_eq!(
hostname_for("Host x\n IdentityFile ~/.ssh/id_x\n", "x"),
None
);
}
}