gitswitch 0.1.0

Switch between GitHub accounts (git identity + gh auth) on the fly
//! Account profiles and their on-disk config file.
//!
//! Pure serde/toml + filesystem. No process spawning lives here.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};

/// Subcommand names that cannot be used as an account alias, so that the bare
/// `gitswitch <alias>` dispatch stays unambiguous.
pub const RESERVED_ALIASES: &[&str] = &["add", "list", "current", "remove", "help"];

/// A single GitHub account profile.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
    pub name: String,
    pub email: String,
    pub gh_user: String,
}

/// The whole config file: a map of alias -> account.
///
/// `BTreeMap` keeps `list` output stable and alphabetically sorted.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub accounts: BTreeMap<String, Account>,
}

/// True if `alias` collides with a reserved subcommand name.
pub fn is_reserved(alias: &str) -> bool {
    RESERVED_ALIASES.contains(&alias)
}

impl Config {
    /// Default config path: `<config-dir>/gitswitch/config.toml`.
    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"))
    }

    /// Load config from `path`. A missing file is treated as empty config so a
    /// fresh install "just works" until the first `add`.
    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()))
            }
        }
    }

    /// Serialize and write config to `path`, creating parent dirs as needed.
    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()))
    }

    /// Insert or overwrite an account, rejecting reserved aliases.
    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(())
    }

    /// Remove an account, erroring if the alias is unknown.
    pub fn remove_account(&mut self, alias: &str) -> Result<Account> {
        self.accounts
            .remove(alias)
            .with_context(|| format!("no account named '{alias}' (see `gitswitch list`)"))
    }

    /// Look up an account by alias.
    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);
    }
}