use trusty_mpm::core::trusty_tools_config::{DEFAULT_GITHUB_HOST, GithubConfig};
const ENV_GH_CONFIG_DIR: &str = "GH_CONFIG_DIR";
const ENV_GH_TOKEN: &str = "GH_TOKEN";
const ENV_GH_HOST: &str = "GH_HOST";
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(crate) enum GhIdentityError {
#[error(
"github.account = '{0}' cannot select a gh identity on its own without \
mutating global gh state (`gh auth switch`). Bind this project's \
identity with `github.config_dir` (a per-account gh config home) or \
`github.token_env` (the NAME of an env var holding a token) instead."
)]
AccountStrategyUnsupported(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct GhEnv {
vars: Vec<(String, String)>,
}
impl GhEnv {
pub(crate) fn vars(&self) -> &[(String, String)] {
&self.vars
}
pub(crate) fn is_empty(&self) -> bool {
self.vars.is_empty()
}
}
pub(crate) fn resolve_gh_env(config: Option<&GithubConfig>) -> Result<GhEnv, GhIdentityError> {
let Some(cfg) = config else {
return Ok(GhEnv::default());
};
let mut vars: Vec<(String, String)> = Vec::new();
if let Some(dir) = cfg
.config_dir
.as_deref()
.map(|p| p.to_string_lossy().trim().to_string())
.filter(|s| !s.is_empty())
{
vars.push((ENV_GH_CONFIG_DIR.to_string(), dir));
} else if let Some(token) = resolve_token_env(cfg.token_env.as_deref()) {
vars.push((ENV_GH_TOKEN.to_string(), token));
} else if let Some(account) = cfg
.account
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Err(GhIdentityError::AccountStrategyUnsupported(
account.to_string(),
));
}
if let Some(host) = cfg.host.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
vars.push((ENV_GH_HOST.to_string(), host.to_string()));
}
Ok(GhEnv { vars })
}
fn resolve_token_env(token_env: Option<&str>) -> Option<String> {
let name = token_env.map(str::trim).filter(|s| !s.is_empty())?;
let value = std::env::var(name).ok()?;
if value.is_empty() { None } else { Some(value) }
}
pub(crate) fn effective_host(config: Option<&GithubConfig>) -> String {
config
.and_then(|c| c.host.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_GITHUB_HOST)
.to_string()
}
pub(crate) fn clone_url(config: Option<&GithubConfig>, repo: &str) -> String {
format!("https://{}/{}", effective_host(config), repo)
}
pub(crate) fn resolve_gh_env_anyhow(config: Option<&GithubConfig>) -> anyhow::Result<GhEnv> {
resolve_gh_env(config).map_err(|e| anyhow::anyhow!(e))
}
pub(crate) fn load_gh_env() -> anyhow::Result<GhEnv> {
let config = trusty_mpm::core::trusty_tools_config::TrustyToolsConfig::load();
let env = resolve_gh_env_anyhow(config.github.as_ref())?;
if !env.is_empty() {
let names: Vec<&str> = env.vars().iter().map(|(k, _)| k.as_str()).collect();
tracing::debug!(overrides = ?names, "applying per-project GitHub identity binding to gh calls");
}
Ok(env)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn cfg() -> GithubConfig {
GithubConfig::default()
}
#[test]
fn resolve_absent_config_is_empty() {
let env = resolve_gh_env(None).expect("none is ok");
assert!(env.is_empty());
assert_eq!(env.vars(), &[]);
}
#[test]
fn resolve_config_dir() {
let c = GithubConfig {
config_dir: Some(PathBuf::from("/home/bob/.config/gh-work")),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert_eq!(
env.vars(),
&[(
ENV_GH_CONFIG_DIR.to_string(),
"/home/bob/.config/gh-work".to_string()
)]
);
}
#[test]
fn resolve_token_env_present() {
let _g = env_lock();
let name = "TM_TEST_GH_TOKEN_PRESENT";
unsafe { std::env::set_var(name, "ghp_secret_value") };
let c = GithubConfig {
token_env: Some(name.to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
unsafe { std::env::remove_var(name) };
assert_eq!(
env.vars(),
&[(ENV_GH_TOKEN.to_string(), "ghp_secret_value".to_string())]
);
}
#[test]
fn resolve_token_env_absent_falls_through() {
let _g = env_lock();
let name = "TM_TEST_GH_TOKEN_DEFINITELY_UNSET";
unsafe { std::env::remove_var(name) };
let c = GithubConfig {
token_env: Some(name.to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert!(env.is_empty(), "expected empty, got {:?}", env.vars());
}
#[test]
fn precedence_config_dir_beats_token_env() {
let _g = env_lock();
let name = "TM_TEST_GH_TOKEN_PRECEDENCE";
unsafe { std::env::set_var(name, "tok") };
let c = GithubConfig {
config_dir: Some(PathBuf::from("/cfg/dir")),
token_env: Some(name.to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
unsafe { std::env::remove_var(name) };
assert_eq!(
env.vars(),
&[(ENV_GH_CONFIG_DIR.to_string(), "/cfg/dir".to_string())]
);
}
#[test]
fn precedence_token_env_beats_account() {
let _g = env_lock();
let name = "TM_TEST_GH_TOKEN_BEATS_ACCOUNT";
unsafe { std::env::set_var(name, "tok2") };
let c = GithubConfig {
token_env: Some(name.to_string()),
account: Some("bob-work".to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
unsafe { std::env::remove_var(name) };
assert_eq!(
env.vars(),
&[(ENV_GH_TOKEN.to_string(), "tok2".to_string())]
);
}
#[test]
fn account_only_is_refused() {
let c = GithubConfig {
account: Some("bob-work".to_string()),
..cfg()
};
let err = resolve_gh_env(Some(&c)).unwrap_err();
assert_eq!(
err,
GhIdentityError::AccountStrategyUnsupported("bob-work".to_string())
);
let msg = err.to_string();
assert!(msg.contains("config_dir"), "msg: {msg}");
assert!(msg.contains("token_env"), "msg: {msg}");
}
#[test]
fn account_with_config_dir_is_ok() {
let c = GithubConfig {
config_dir: Some(PathBuf::from("/cfg/acct")),
account: Some("bob-work".to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert_eq!(
env.vars(),
&[(ENV_GH_CONFIG_DIR.to_string(), "/cfg/acct".to_string())]
);
}
#[test]
fn host_always_applied() {
let c = GithubConfig {
host: Some("github.example.com".to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert_eq!(
env.vars(),
&[(ENV_GH_HOST.to_string(), "github.example.com".to_string())]
);
}
#[test]
fn host_applied_with_identity() {
let c = GithubConfig {
config_dir: Some(PathBuf::from("/cfg")),
host: Some("ghe.corp".to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert_eq!(
env.vars(),
&[
(ENV_GH_CONFIG_DIR.to_string(), "/cfg".to_string()),
(ENV_GH_HOST.to_string(), "ghe.corp".to_string()),
]
);
}
#[test]
fn effective_host_default() {
assert_eq!(effective_host(None), "github.com");
assert_eq!(effective_host(Some(&cfg())), "github.com");
}
#[test]
fn effective_host_override() {
let c = GithubConfig {
host: Some("github.example.com".to_string()),
..cfg()
};
assert_eq!(effective_host(Some(&c)), "github.example.com");
}
#[test]
fn clone_url_default_host() {
assert_eq!(
clone_url(None, "bobmatnyc/trusty-tools"),
"https://github.com/bobmatnyc/trusty-tools"
);
}
#[test]
fn clone_url_enterprise_host() {
let c = GithubConfig {
host: Some("github.example.com".to_string()),
..cfg()
};
assert_eq!(
clone_url(Some(&c), "acme/widget"),
"https://github.example.com/acme/widget"
);
}
}