gitswitch 0.1.0

Switch between GitHub accounts (git identity + gh auth) on the fly
//! Thin wrappers around the GitHub `gh` CLI.

use std::process::{Command, Stdio};

use anyhow::{Context, Result};

/// Outcome of trying to switch the active `gh` account.
pub enum SwitchResult {
    /// `gh auth switch` succeeded.
    Ok,
    /// `gh` ran but returned an error (e.g. account not logged in).
    Failed(String),
    /// `gh` is not installed / not on PATH.
    NotInstalled,
}

/// Switch the active `gh` account to `gh_user`.
pub fn switch(gh_user: &str) -> Result<SwitchResult> {
    let output = Command::new("gh")
        .args(["auth", "switch", "--user", gh_user])
        .output();
    match output {
        Ok(out) if out.status.success() => Ok(SwitchResult::Ok),
        Ok(out) => Ok(SwitchResult::Failed(
            String::from_utf8_lossy(&out.stderr).trim().to_string(),
        )),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(SwitchResult::NotInstalled),
        Err(e) => Err(e.into()),
    }
}

/// Run `gh auth login` interactively, inheriting the terminal so the user can
/// complete the browser / device-code flow. Returns whether it succeeded.
///
/// `gh auth login` does not accept a target username (the account is decided by
/// the flow itself), so we log in to github.com and let the caller re-attempt
/// the switch afterwards.
pub fn login() -> Result<bool> {
    let status = Command::new("gh")
        .args(["auth", "login", "--hostname", "github.com"])
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .context("failed to run `gh auth login`")?;
    Ok(status.success())
}

/// The currently-active `gh` user, if `gh` is installed and logged in.
pub fn current_user() -> Option<String> {
    let output = Command::new("gh")
        .args(["auth", "status", "--active"])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    parse_active_user(&text).or_else(|| parse_active_user(&stderr))
}

/// Extract the logged-in account from `gh auth status` output.
///
/// Lines look like: `  ✓ Logged in to github.com account octocat (keyring)`.
fn parse_active_user(text: &str) -> Option<String> {
    for line in text.lines() {
        if let Some(idx) = line.find("account ") {
            let rest = &line[idx + "account ".len()..];
            let user = rest.split_whitespace().next()?;
            if !user.is_empty() {
                return Some(user.to_string());
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_active_user_from_status_line() {
        let text = "github.com\n  ✓ Logged in to github.com account octocat (keyring)\n  - Active account: true";
        assert_eq!(parse_active_user(text), Some("octocat".to_string()));
    }

    #[test]
    fn returns_none_when_no_account_line() {
        assert_eq!(parse_active_user("You are not logged in"), None);
    }
}