tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Token resolution with the contract's precedence: env var, then
//! `tix.toml` `[export]`, then the CLI's native auth.
//!
//! `contracts/external-tools.md` §Token resolution: `GITLAB_TOKEN` /
//! `LINEAR_TOKEN` (and, for Linear, the tool's own `LINEAR_API_KEY`) beat a
//! `tix.toml` token (which itself never reaches disk),
//! and tokens are injected only as an environment pair — never in argv. The
//! [`EnvReader`] seam keeps precedence unit-testable without touching process
//! state; a token found in `tix.toml` triggers the shared "prefer the env
//! var" warning once per run.

use std::env;

use crate::config::Config;
use crate::sync::Tool;

/// Where the secret ultimately came from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenResolution {
    /// `GITLAB_TOKEN` / `LINEAR_TOKEN` / `LINEAR_API_KEY` was present in the
    /// process env.
    Env(String),
    /// `token` was read from the `[export]` section of `tix.toml`.
    Config(String),
    /// Neither — the CLI's native auth (glab keyring / linear-cli auth) is
    /// used and no token is injected.
    Native,
}

impl TokenResolution {
    /// The secret to scrub from error/report surfaces, if any.
    #[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"),
        }
    }
}

/// Read access to the process environment, injectable in tests.
pub trait EnvReader {
    fn get(&self, key: &str) -> Option<String>;
}

/// The real process environment.
pub struct RealEnv;

impl EnvReader for RealEnv {
    fn get(&self, key: &str) -> Option<String> {
        env::var(key).ok()
    }
}

/// Resolve the token for `tool` per `contracts/external-tools.md`
/// §Token resolution. Emits a warning through `warn` exactly once when a
/// cleartext `tix.toml` token is used (the env var is always preferred and
/// the file token is never written back out).
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();
        // `LINEAR_API_KEY` belongs to Linear and must never be read for GitLab.
        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"));
    }
}