use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
pub const RESERVED_ALIASES: &[&str] = &["add", "list", "current", "remove", "help"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
pub name: String,
pub email: String,
pub gh_user: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub accounts: BTreeMap<String, Account>,
}
pub fn is_reserved(alias: &str) -> bool {
RESERVED_ALIASES.contains(&alias)
}
impl Config {
pub fn default_path() -> Result<PathBuf> {
let dirs = ProjectDirs::from("", "", "gitswitch")
.context("could not determine a config directory for this platform")?;
Ok(dirs.config_dir().join("config.toml"))
}
pub fn load(path: &Path) -> Result<Config> {
match std::fs::read_to_string(path) {
Ok(text) => toml::from_str(&text)
.with_context(|| format!("failed to parse config at {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
Err(e) => {
Err(e).with_context(|| format!("failed to read config at {}", path.display()))
}
}
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
}
let text = toml::to_string_pretty(self).context("failed to serialize config")?;
std::fs::write(path, text)
.with_context(|| format!("failed to write config at {}", path.display()))
}
pub fn set_account(&mut self, alias: &str, account: Account) -> Result<()> {
if is_reserved(alias) {
bail!(
"'{alias}' is a reserved command name and cannot be used as an account alias"
);
}
if alias.trim().is_empty() {
bail!("account alias cannot be empty");
}
self.accounts.insert(alias.to_string(), account);
Ok(())
}
pub fn remove_account(&mut self, alias: &str) -> Result<Account> {
self.accounts
.remove(alias)
.with_context(|| format!("no account named '{alias}' (see `gitswitch list`)"))
}
pub fn get(&self, alias: &str) -> Option<&Account> {
self.accounts.get(alias)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Account {
Account {
name: "Jane Doe".into(),
email: "jane@example.com".into(),
gh_user: "jane".into(),
}
}
#[test]
fn load_missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope/config.toml");
let cfg = Config::load(&path).unwrap();
assert!(cfg.accounts.is_empty());
}
#[test]
fn save_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut cfg = Config::default();
cfg.set_account("work", sample()).unwrap();
cfg.save(&path).unwrap();
let loaded = Config::load(&path).unwrap();
assert_eq!(cfg, loaded);
assert_eq!(loaded.get("work"), Some(&sample()));
}
#[test]
fn set_account_rejects_reserved_alias() {
let mut cfg = Config::default();
for &reserved in RESERVED_ALIASES {
assert!(
cfg.set_account(reserved, sample()).is_err(),
"'{reserved}' should be rejected"
);
}
}
#[test]
fn set_account_rejects_empty_alias() {
let mut cfg = Config::default();
assert!(cfg.set_account(" ", sample()).is_err());
}
#[test]
fn remove_unknown_alias_errors() {
let mut cfg = Config::default();
assert!(cfg.remove_account("ghost").is_err());
}
#[test]
fn set_account_overwrites() {
let mut cfg = Config::default();
cfg.set_account("work", sample()).unwrap();
let updated = Account {
email: "new@example.com".into(),
..sample()
};
cfg.set_account("work", updated.clone()).unwrap();
assert_eq!(cfg.get("work"), Some(&updated));
assert_eq!(cfg.accounts.len(), 1);
}
}