mermaid-cli 0.18.0

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
//! Provider API-key resolution.
//!
//! Mermaid's auth surface is uniform across providers: an API key lives in
//! an environment variable, with the option to override the variable name
//! per-provider in `config.toml`, and — since `mermaid login` — an optional
//! OS-keyring fallback. Precedence is strict: env vars are ABSOLUTE; the
//! keyring only fills the gap when no env var resolves. A per-provider
//! `api_key_env` override is AUTHORITATIVE: when set, neither the default
//! env nor the keyring is consulted (fail loudly, as before). There's no
//! in-config secret storage — keys never sit in config.toml in plaintext.

/// Resolve an API key from the environment.
///
/// `default_env` is the env var the built-in registry expects (e.g.
/// `"GROQ_API_KEY"`). `override_env` is an optional per-provider
/// override from `config.toml`'s `[providers.<name>] api_key_env = ...`.
/// When set, it takes precedence — a user who's already standardized on
/// `LLM_API_KEY` for everything can point all their providers at it.
///
/// Empty values are treated as unset (matches the existing
/// `get_cloud_api_key` semantics).
pub fn resolve_api_key(default_env: &str, override_env: Option<&str>) -> Option<String> {
    let env_var = override_env.unwrap_or(default_env);
    match std::env::var(env_var) {
        Ok(key) if !key.is_empty() => Some(key),
        _ => None,
    }
}

/// Resolve a provider's API key: env (default or authoritative override)
/// first, then the OS keyring (`mermaid login <provider>`).
pub fn resolve_provider_key(
    provider: &str,
    default_env: &str,
    override_env: Option<&str>,
) -> Option<String> {
    resolve_provider_key_in(
        super::credentials::default_store(),
        provider,
        default_env,
        override_env,
    )
}

/// [`resolve_provider_key`] against an explicit store (test seam).
pub(crate) fn resolve_provider_key_in(
    store: &dyn super::credentials::CredentialStore,
    provider: &str,
    default_env: &str,
    override_env: Option<&str>,
) -> Option<String> {
    if override_env.is_some() {
        // The user pointed at a specific env var; a stored keyring secret
        // must not silently override that decision (mirrors the existing
        // fail-loudly rule for unset overrides).
        return resolve_api_key(default_env, override_env);
    }
    resolve_api_key(default_env, None).or_else(|| store.get(provider))
}

/// [`resolve_provider_key`] for the one legacy-fallback-env case (Gemini).
pub fn resolve_provider_key_with_fallback(
    provider: &str,
    default_env: &str,
    fallback_env: &str,
    override_env: Option<&str>,
) -> Option<String> {
    if override_env.is_some() {
        return resolve_api_key(default_env, override_env);
    }
    resolve_api_key_with_fallback(default_env, fallback_env, None)
        .or_else(|| super::credentials::default_store().get(provider))
}

/// Where a provider's key would come from right now: `"env"`, `"keyring"`,
/// or `"none"`. Drives `doctor` / `mermaid login` / feedback reporting.
pub fn provider_key_source(
    provider: &str,
    default_env: &str,
    override_env: Option<&str>,
) -> &'static str {
    if resolve_api_key(default_env, override_env).is_some() {
        return "env";
    }
    if override_env.is_none() && super::credentials::default_store().get(provider).is_some() {
        return "keyring";
    }
    "none"
}

/// Resolve an API key with a legacy fallback env var.
///
/// If `override_env` is set, it remains authoritative and no fallback
/// is attempted. Without an override, `default_env` is checked first
/// and `fallback_env` is accepted only when the default is unset.
pub fn resolve_api_key_with_fallback(
    default_env: &str,
    fallback_env: &str,
    override_env: Option<&str>,
) -> Option<String> {
    if override_env.is_some() {
        return resolve_api_key(default_env, override_env);
    }
    resolve_api_key(default_env, None).or_else(|| resolve_api_key(fallback_env, None))
}

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

    /// Generate a unique env var name per test so concurrent test runs
    /// don't step on each other's process-global env state. `temp_env`
    /// restores the prior value after the closure, but a collision with
    /// a concurrent test from another module on a shared name would
    /// still race — unique names are belt-and-braces.
    fn unique_env(prefix: &str) -> String {
        use std::sync::atomic::{AtomicUsize, Ordering};
        static N: AtomicUsize = AtomicUsize::new(0);
        format!(
            "{}_{}_{}",
            prefix,
            std::process::id(),
            N.fetch_add(1, Ordering::SeqCst)
        )
    }

    #[test]
    fn provider_key_env_beats_keyring() {
        use crate::utils::credentials::test_support::FakeStore;
        let store = FakeStore::default();
        store
            .entries
            .lock()
            .unwrap()
            .insert("groq".to_string(), "stored-key".to_string());
        let var = unique_env("MERMAID_TEST_PK_ENV");
        temp_env::with_var(&var, Some("env-key"), || {
            assert_eq!(
                resolve_provider_key_in(&store, "groq", &var, None),
                Some("env-key".to_string()),
                "env must have absolute precedence"
            );
        });
        temp_env::with_var_unset(&var, || {
            assert_eq!(
                resolve_provider_key_in(&store, "groq", &var, None),
                Some("stored-key".to_string()),
                "keyring fills the gap when env is unset"
            );
        });
    }

    #[test]
    fn override_env_blocks_keyring_fallback() {
        use crate::utils::credentials::test_support::FakeStore;
        let store = FakeStore::default();
        store
            .entries
            .lock()
            .unwrap()
            .insert("groq".to_string(), "stored-key".to_string());
        let default_var = unique_env("MERMAID_TEST_PK_DEFAULT");
        let override_var = unique_env("MERMAID_TEST_PK_OVERRIDE");
        // Override set but its var unset: fail loudly — no default env, no
        // keyring (the user explicitly redirected auth).
        temp_env::with_vars(
            [
                (default_var.as_str(), Some("default-key")),
                (override_var.as_str(), None),
            ],
            || {
                assert_eq!(
                    resolve_provider_key_in(&store, "groq", &default_var, Some(&override_var)),
                    None
                );
            },
        );
    }

    #[test]
    fn returns_none_when_env_var_unset() {
        let var = unique_env("MERMAID_TEST_AUTH_UNSET");
        temp_env::with_var_unset(&var, || {
            assert_eq!(resolve_api_key(&var, None), None);
        });
    }

    #[test]
    fn returns_value_when_env_var_set() {
        let var = unique_env("MERMAID_TEST_AUTH_SET");
        temp_env::with_var(&var, Some("secret-value"), || {
            assert_eq!(
                resolve_api_key(&var, None),
                Some("secret-value".to_string())
            );
        });
    }

    #[test]
    fn empty_string_treated_as_unset() {
        let var = unique_env("MERMAID_TEST_AUTH_EMPTY");
        temp_env::with_var(&var, Some(""), || {
            assert_eq!(resolve_api_key(&var, None), None);
        });
    }

    #[test]
    fn override_env_takes_precedence_over_default() {
        let default_var = unique_env("MERMAID_TEST_AUTH_DEFAULT");
        let override_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE");
        temp_env::with_vars(
            [
                (default_var.as_str(), Some("default-key")),
                (override_var.as_str(), Some("override-key")),
            ],
            || {
                let resolved = resolve_api_key(&default_var, Some(&override_var));
                assert_eq!(resolved, Some("override-key".to_string()));
            },
        );
    }

    #[test]
    fn override_env_unset_falls_through_to_none() {
        // When the override env name is provided but unset, we DON'T fall
        // back to the default — the user explicitly asked for a different
        // var and got nothing. Better to fail loudly than silently use a
        // key the user thought they'd disabled.
        let default_var = unique_env("MERMAID_TEST_AUTH_DEFAULT2");
        let override_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE2");
        temp_env::with_vars(
            [
                (default_var.as_str(), Some("default-key")),
                (override_var.as_str(), None),
            ],
            || {
                let resolved = resolve_api_key(&default_var, Some(&override_var));
                assert_eq!(resolved, None);
            },
        );
    }

    #[test]
    fn fallback_env_used_only_when_default_is_unset() {
        let default_var = unique_env("MERMAID_TEST_AUTH_FALLBACK_DEFAULT");
        let fallback_var = unique_env("MERMAID_TEST_AUTH_FALLBACK_LEGACY");
        temp_env::with_vars(
            [
                (default_var.as_str(), None),
                (fallback_var.as_str(), Some("legacy-key")),
            ],
            || {
                let resolved = resolve_api_key_with_fallback(&default_var, &fallback_var, None);
                assert_eq!(resolved, Some("legacy-key".to_string()));
            },
        );

        temp_env::with_vars(
            [
                (default_var.as_str(), Some("default-key")),
                (fallback_var.as_str(), Some("legacy-key")),
            ],
            || {
                let resolved = resolve_api_key_with_fallback(&default_var, &fallback_var, None);
                assert_eq!(resolved, Some("default-key".to_string()));
            },
        );
    }

    #[test]
    fn fallback_env_is_ignored_when_override_is_set() {
        let default_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE_DEFAULT3");
        let fallback_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE_FALLBACK3");
        let override_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE3");
        temp_env::with_vars(
            [
                (default_var.as_str(), Some("default-key")),
                (fallback_var.as_str(), Some("legacy-key")),
                (override_var.as_str(), None),
            ],
            || {
                let resolved =
                    resolve_api_key_with_fallback(&default_var, &fallback_var, Some(&override_var));
                assert_eq!(resolved, None);
            },
        );
    }
}