mermaid_cli/utils/auth.rs
1//! Provider API-key resolution.
2//!
3//! Mermaid's auth surface is uniform across providers: an API key lives in
4//! an environment variable, with the option to override the variable name
5//! per-provider in `config.toml`. There's no in-config secret storage —
6//! keys never sit on disk in plaintext via this helper. (The legacy
7//! `cloud_api_key` field on `[ollama]` predates this and writes the key
8//! to disk; that path stays for backward compat but is not what new
9//! providers should use.)
10
11/// Resolve an API key from the environment.
12///
13/// `default_env` is the env var the built-in registry expects (e.g.
14/// `"GROQ_API_KEY"`). `override_env` is an optional per-provider
15/// override from `config.toml`'s `[providers.<name>] api_key_env = ...`.
16/// When set, it takes precedence — a user who's already standardized on
17/// `LLM_API_KEY` for everything can point all their providers at it.
18///
19/// Empty values are treated as unset (matches the existing
20/// `get_cloud_api_key` semantics).
21pub fn resolve_api_key(default_env: &str, override_env: Option<&str>) -> Option<String> {
22 let env_var = override_env.unwrap_or(default_env);
23 match std::env::var(env_var) {
24 Ok(key) if !key.is_empty() => Some(key),
25 _ => None,
26 }
27}
28
29#[cfg(test)]
30mod tests {
31 use super::*;
32
33 /// Generate a unique env var name per test so concurrent test runs
34 /// don't step on each other's process-global env state. `temp_env`
35 /// restores the prior value after the closure, but a collision with
36 /// a concurrent test from another module on a shared name would
37 /// still race — unique names are belt-and-braces.
38 fn unique_env(prefix: &str) -> String {
39 use std::sync::atomic::{AtomicUsize, Ordering};
40 static N: AtomicUsize = AtomicUsize::new(0);
41 format!(
42 "{}_{}_{}",
43 prefix,
44 std::process::id(),
45 N.fetch_add(1, Ordering::SeqCst)
46 )
47 }
48
49 #[test]
50 fn returns_none_when_env_var_unset() {
51 let var = unique_env("MERMAID_TEST_AUTH_UNSET");
52 temp_env::with_var_unset(&var, || {
53 assert_eq!(resolve_api_key(&var, None), None);
54 });
55 }
56
57 #[test]
58 fn returns_value_when_env_var_set() {
59 let var = unique_env("MERMAID_TEST_AUTH_SET");
60 temp_env::with_var(&var, Some("secret-value"), || {
61 assert_eq!(
62 resolve_api_key(&var, None),
63 Some("secret-value".to_string())
64 );
65 });
66 }
67
68 #[test]
69 fn empty_string_treated_as_unset() {
70 let var = unique_env("MERMAID_TEST_AUTH_EMPTY");
71 temp_env::with_var(&var, Some(""), || {
72 assert_eq!(resolve_api_key(&var, None), None);
73 });
74 }
75
76 #[test]
77 fn override_env_takes_precedence_over_default() {
78 let default_var = unique_env("MERMAID_TEST_AUTH_DEFAULT");
79 let override_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE");
80 temp_env::with_vars(
81 [
82 (default_var.as_str(), Some("default-key")),
83 (override_var.as_str(), Some("override-key")),
84 ],
85 || {
86 let resolved = resolve_api_key(&default_var, Some(&override_var));
87 assert_eq!(resolved, Some("override-key".to_string()));
88 },
89 );
90 }
91
92 #[test]
93 fn override_env_unset_falls_through_to_none() {
94 // When the override env name is provided but unset, we DON'T fall
95 // back to the default — the user explicitly asked for a different
96 // var and got nothing. Better to fail loudly than silently use a
97 // key the user thought they'd disabled.
98 let default_var = unique_env("MERMAID_TEST_AUTH_DEFAULT2");
99 let override_var = unique_env("MERMAID_TEST_AUTH_OVERRIDE2");
100 temp_env::with_vars(
101 [
102 (default_var.as_str(), Some("default-key")),
103 (override_var.as_str(), None),
104 ],
105 || {
106 let resolved = resolve_api_key(&default_var, Some(&override_var));
107 assert_eq!(resolved, None);
108 },
109 );
110 }
111}