use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
#[serde(default)]
pub macos: MacosConfig,
#[serde(rename = "rule", default)]
pub rules: Vec<Rule>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MacosConfig {
#[serde(default = "default_use_keychain")]
pub use_keychain: bool,
}
fn default_use_keychain() -> bool {
true
}
impl Default for MacosConfig {
fn default() -> Self {
Self {
use_keychain: default_use_keychain(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct Rule {
pub host: String,
#[serde(rename = "match")]
pub match_pattern: Option<String>,
pub key: String,
pub email: Option<String>,
pub name: Option<String>,
pub port: Option<u16>,
#[serde(default)]
pub auto: bool,
}
impl Rule {
pub fn expanded_key(&self) -> PathBuf {
expand_tilde(&self.key)
}
}
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
if path == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
PathBuf::from(path)
}
pub fn default_config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".config")
.join("pickey")
.join("config.toml")
}
pub fn load_config(path: Option<&Path>) -> Result<Config, String> {
let config_path = match path {
Some(p) => p.to_path_buf(),
None => default_config_path(),
};
if !config_path.exists() {
return Err(format!("Config file not found: {}", config_path.display()));
}
let contents = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read {}: {}", config_path.display(), e))?;
let config: Config = toml::from_str(&contents)
.map_err(|e| format!("Failed to parse {}: {}", config_path.display(), e))?;
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_full_config() {
let toml = r#"
[macos]
use_keychain = true
[[rule]]
host = "github.com"
match = "VolvoGroup-Internal/*"
key = "~/.ssh/id_volvo"
email = "simeon@volvo.com"
name = "Simeon Volvo"
[[rule]]
host = "github.com"
match = "MyPersonalOrg/*"
key = "~/.ssh/id_personal"
[[rule]]
host = "ssh.dev.azure.com"
match = "v3/ClientX/**"
key = "~/.ssh/id_clientx"
email = "simeon@clientx.com"
[[rule]]
host = "gitlab.selfhosted.client.com"
key = "~/.ssh/id_client_gitlab"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.macos.use_keychain);
assert_eq!(config.rules.len(), 4);
assert_eq!(config.rules[0].host, "github.com");
assert_eq!(
config.rules[0].match_pattern.as_deref(),
Some("VolvoGroup-Internal/*")
);
assert_eq!(config.rules[0].email.as_deref(), Some("simeon@volvo.com"));
assert!(config.rules[1].email.is_none());
assert!(config.rules[3].match_pattern.is_none());
}
#[test]
fn macos_config_defaults_to_keychain_enabled() {
let toml = r#"
[[rule]]
host = "github.com"
key = "~/.ssh/id_personal"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.macos.use_keychain);
}
#[test]
fn macos_config_explicit_disable() {
let toml = r#"
[macos]
use_keychain = false
[[rule]]
host = "github.com"
key = "~/.ssh/id_personal"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(!config.macos.use_keychain);
}
#[test]
fn tilde_expansion() {
let expanded = expand_tilde("~/.ssh/id_rsa");
assert!(expanded.to_str().unwrap().contains(".ssh/id_rsa"));
assert!(!expanded.to_str().unwrap().starts_with('~'));
}
#[test]
fn no_tilde() {
let expanded = expand_tilde("/absolute/path/key");
assert_eq!(expanded, PathBuf::from("/absolute/path/key"));
}
}