use std::env;
use crate::config::Config;
use crate::sync::Tool;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenResolution {
Env(String),
Config(String),
Native,
}
impl TokenResolution {
#[must_use]
pub fn secret(&self) -> Option<&str> {
match self {
TokenResolution::Env(s) | TokenResolution::Config(s) => Some(s),
TokenResolution::Native => None,
}
}
}
impl std::fmt::Display for TokenResolution {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TokenResolution::Env(_) => {
write!(f, "from GITLAB_TOKEN/LINEAR_TOKEN/LINEAR_API_KEY env var")
}
TokenResolution::Config(_) => write!(f, "from tix.toml"),
TokenResolution::Native => write!(f, "native CLI auth"),
}
}
}
pub trait EnvReader {
fn get(&self, key: &str) -> Option<String>;
}
pub struct RealEnv;
impl EnvReader for RealEnv {
fn get(&self, key: &str) -> Option<String> {
env::var(key).ok()
}
}
pub fn resolve(
tool: Tool,
config: &Config,
env: &dyn EnvReader,
warn: &mut dyn FnMut(&str),
) -> TokenResolution {
for var in tool.token_vars() {
if let Some(value) = env.get(var).filter(|v| !v.trim().is_empty()) {
return TokenResolution::Env(value);
}
}
let file_token = match tool {
Tool::GitLab => config
.export
.gitlab
.as_ref()
.and_then(|r| r.token.as_deref()),
Tool::Linear => config
.export
.linear
.as_ref()
.and_then(|r| r.token.as_deref()),
};
match file_token {
Some(value) if !value.trim().is_empty() => {
warn(&format!(
"warning: reading {} token from tix.toml — prefer the {} environment variable to avoid committing a secret",
tool.token_var().trim_end_matches("_TOKEN"),
tool.token_var()
));
TokenResolution::Config(value.to_string())
}
_ => TokenResolution::Native,
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::RemoteConfig;
struct TestEnv<'a>(&'a [(&'a str, &'a str)]);
impl EnvReader for TestEnv<'_> {
fn get(&self, key: &str) -> Option<String> {
self.0
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.to_string())
}
}
fn config_with_file_token(tool: Tool) -> Config {
let mut config = Config::default();
match tool {
Tool::GitLab => {
config.export.gitlab = Some(RemoteConfig {
token: Some("gl-secret".into()),
..RemoteConfig::default()
});
}
Tool::Linear => {
config.export.linear = Some(RemoteConfig {
token: Some("lin-secret".into()),
..RemoteConfig::default()
});
}
}
config
}
#[test]
fn env_var_wins_over_file_token_without_warning() {
let config = config_with_file_token(Tool::GitLab);
let env = TestEnv(&[("GITLAB_TOKEN", "env-secret")]);
let mut warned = false;
let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| warned = true);
assert_eq!(resolved, TokenResolution::Env("env-secret".into()));
assert!(!warned, "env token must not warn");
}
#[test]
fn linear_token_wins_over_linear_api_key() {
let config = Config::default();
let env = TestEnv(&[
("LINEAR_TOKEN", "tix-name"),
("LINEAR_API_KEY", "tool-name"),
]);
let resolved = resolve(Tool::Linear, &config, &env, &mut |_| {});
assert_eq!(resolved, TokenResolution::Env("tix-name".into()));
}
#[test]
fn linear_api_key_alone_is_read() {
let config = Config::default();
let env = TestEnv(&[("LINEAR_API_KEY", "tool-name")]);
let resolved = resolve(Tool::Linear, &config, &env, &mut |_| {});
assert_eq!(resolved, TokenResolution::Env("tool-name".into()));
}
#[test]
fn a_blank_linear_token_falls_through_to_the_api_key() {
let config = Config::default();
let env = TestEnv(&[("LINEAR_TOKEN", " "), ("LINEAR_API_KEY", "tool-name")]);
let resolved = resolve(Tool::Linear, &config, &env, &mut |_| {});
assert_eq!(resolved, TokenResolution::Env("tool-name".into()));
}
#[test]
fn a_blank_linear_api_key_still_falls_through_to_the_file_token() {
let config = config_with_file_token(Tool::Linear);
let env = TestEnv(&[("LINEAR_API_KEY", "")]);
let mut warned = false;
let resolved = resolve(Tool::Linear, &config, &env, &mut |_| warned = true);
assert_eq!(resolved, TokenResolution::Config("lin-secret".into()));
assert!(warned, "the file token still warns");
}
#[test]
fn gitlab_reads_only_its_own_token_var() {
let config = Config::default();
let env = TestEnv(&[("LINEAR_API_KEY", "tool-name")]);
let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| {});
assert_eq!(resolved, TokenResolution::Native);
}
#[test]
fn empty_env_var_falls_back_to_file_token_and_warns() {
let config = config_with_file_token(Tool::Linear);
let env = TestEnv(&[("LINEAR_TOKEN", "")]);
let mut warnings: Vec<String> = Vec::new();
let resolved = resolve(Tool::Linear, &config, &env, &mut |w| {
warnings.push(w.to_string())
});
assert_eq!(resolved, TokenResolution::Config("lin-secret".into()));
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0],
"warning: reading LINEAR token from tix.toml — prefer the LINEAR_TOKEN environment variable to avoid committing a secret"
);
assert!(
!warnings[0].contains("lin-secret"),
"warning must not echo the secret: {}",
warnings[0]
);
}
#[test]
fn file_token_warns_once_and_not_for_other_tool() {
let config = config_with_file_token(Tool::GitLab);
let env = TestEnv(&[]);
let mut warnings: Vec<String> = Vec::new();
let resolved = resolve(Tool::GitLab, &config, &env, &mut |w| {
warnings.push(w.to_string())
});
assert_eq!(resolved, TokenResolution::Config("gl-secret".into()));
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0],
"warning: reading GITLAB token from tix.toml — prefer the GITLAB_TOKEN environment variable to avoid committing a secret"
);
}
#[test]
fn native_auth_when_neither_source_configured() {
let config = Config::default();
let env = TestEnv(&[]);
let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| {});
assert_eq!(resolved, TokenResolution::Native);
assert_eq!(resolved.secret(), None);
}
#[test]
fn display_never_echoes_the_secret() {
let resolved = TokenResolution::Env("sekrit".into());
assert!(!resolved.to_string().contains("sekrit"));
let config_resolved = TokenResolution::Config("sekrit".into());
assert!(!config_resolved.to_string().contains("sekrit"));
}
}