gestura_core_config/
config_env.rs

1//! Environment Variable Configuration Support
2//!
3//! Provides hierarchical configuration loading with precedence:
4//! 1. Environment variables (GESTURA_* prefix)
5//! 2. Config file (~/.gestura/config.yaml)
6//! 3. Default values
7//!
8//! All environment variables use the GESTURA_ prefix and snake_case naming.
9
10use std::env;
11
12/// Environment variable prefix for all Gestura configuration
13pub const ENV_PREFIX: &str = "GESTURA_";
14
15/// Get an environment variable with the GESTURA_ prefix
16pub fn get_env(key: &str) -> Option<String> {
17    let env_key = format!("{}{}", ENV_PREFIX, key.to_uppercase());
18    env::var(&env_key).ok()
19}
20
21/// Get an environment variable as a boolean
22pub fn get_env_bool(key: &str) -> Option<bool> {
23    get_env(key).map(|v| {
24        matches!(
25            v.to_lowercase().as_str(),
26            "true" | "1" | "yes" | "on" | "enabled"
27        )
28    })
29}
30
31/// Get an environment variable as a u32
32pub fn get_env_u32(key: &str) -> Option<u32> {
33    get_env(key).and_then(|v| v.parse().ok())
34}
35
36/// Get an environment variable as a u64
37pub fn get_env_u64(key: &str) -> Option<u64> {
38    get_env(key).and_then(|v| v.parse().ok())
39}
40
41/// Get an environment variable as a usize
42pub fn get_env_usize(key: &str) -> Option<usize> {
43    get_env(key).and_then(|v| v.parse().ok())
44}
45
46/// Environment variable mappings for AppConfig fields
47///
48/// Format: (env_var_suffix, config_path, description)
49pub const ENV_MAPPINGS: &[(&str, &str, &str)] = &[
50    // Core settings
51    (
52        "HOTKEY_LISTEN",
53        "hotkey_listen",
54        "Global hotkey to toggle the app",
55    ),
56    (
57        "HOTKEY_NEW_SESSION",
58        "hotkey_new_session",
59        "Global hotkey to open a new agent session",
60    ),
61    (
62        "GRACE_PERIOD_SECS",
63        "grace_period_secs",
64        "Agent shutdown grace period in seconds",
65    ),
66    ("NATS_URL", "nats_url", "NATS server URL for messaging"),
67    // LLM settings
68    (
69        "LLM_PRIMARY",
70        "llm.primary",
71        "Primary LLM provider (openai, anthropic, grok, ollama)",
72    ),
73    ("LLM_FALLBACK", "llm.fallback", "Fallback LLM provider"),
74    ("OPENAI_API_KEY", "llm.openai.api_key", "OpenAI API key"),
75    (
76        "OPENAI_BASE_URL",
77        "llm.openai.base_url",
78        "OpenAI API base URL",
79    ),
80    ("OPENAI_MODEL", "llm.openai.model", "OpenAI model name"),
81    (
82        "ANTHROPIC_API_KEY",
83        "llm.anthropic.api_key",
84        "Anthropic API key",
85    ),
86    (
87        "ANTHROPIC_BASE_URL",
88        "llm.anthropic.base_url",
89        "Anthropic API base URL",
90    ),
91    (
92        "ANTHROPIC_MODEL",
93        "llm.anthropic.model",
94        "Anthropic model name",
95    ),
96    ("GROK_API_KEY", "llm.grok.api_key", "Grok API key"),
97    ("GROK_BASE_URL", "llm.grok.base_url", "Grok API base URL"),
98    ("GROK_MODEL", "llm.grok.model", "Grok model name"),
99    ("GEMINI_API_KEY", "llm.gemini.api_key", "Gemini API key"),
100    (
101        "GEMINI_BASE_URL",
102        "llm.gemini.base_url",
103        "Gemini API base URL",
104    ),
105    ("GEMINI_MODEL", "llm.gemini.model", "Gemini model name"),
106    (
107        "OLLAMA_BASE_URL",
108        "llm.ollama.base_url",
109        "Ollama server URL",
110    ),
111    ("OLLAMA_MODEL", "llm.ollama.model", "Ollama model name"),
112    // Voice settings
113    (
114        "VOICE_PROVIDER",
115        "voice.provider",
116        "Voice provider (local, openai, none)",
117    ),
118    (
119        "VOICE_LOCAL_MODEL_PATH",
120        "voice.local_model_path",
121        "Path to local Whisper model",
122    ),
123    (
124        "VOICE_OPENAI_API_KEY",
125        "voice.openai_api_key",
126        "OpenAI API key for voice",
127    ),
128    (
129        "VOICE_OPENAI_MODEL",
130        "voice.openai_model",
131        "OpenAI voice model",
132    ),
133    (
134        "VOICE_AUDIO_DEVICE",
135        "voice.audio_device",
136        "Audio input device name",
137    ),
138    // UI settings
139    (
140        "UI_THEME_MODE",
141        "ui.theme_mode",
142        "Theme mode (system, light, dark)",
143    ),
144    ("UI_ACCENT", "ui.accent", "Accent color"),
145    // Developer settings
146    (
147        "DEVELOPER_MODE",
148        "developer.developer_mode",
149        "Enable developer mode",
150    ),
151    (
152        "ENABLE_SIMULATORS",
153        "developer.enable_simulators",
154        "Enable device simulators",
155    ),
156    (
157        "VERBOSE_BLE_LOGGING",
158        "developer.verbose_ble_logging",
159        "Enable verbose BLE logging",
160    ),
161    // Web search settings
162    (
163        "WEB_SEARCH_PROVIDER",
164        "web_search.provider",
165        "Web search provider",
166    ),
167    ("SERPAPI_KEY", "web_search.serpapi_key", "SerpAPI key"),
168    (
169        "BRAVE_SEARCH_KEY",
170        "web_search.brave_key",
171        "Brave Search API key",
172    ),
173];
174
175/// Check if a key is a secret (should be redacted in logs)
176pub fn is_secret_key(key: &str) -> bool {
177    let key_lower = key.to_lowercase();
178    key_lower.contains("api_key")
179        || key_lower.contains("secret")
180        || key_lower.contains("password")
181        || key_lower.contains("token")
182        || key_lower.ends_with("_key")
183}
184
185/// Redact a secret value for logging
186pub fn redact_secret(value: &str) -> String {
187    if value.len() <= 8 {
188        "***".to_string()
189    } else {
190        format!("{}...{}", &value[..4], &value[value.len() - 4..])
191    }
192}
193
194/// API key validation result
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum ApiKeyValidation {
197    /// Key is valid
198    Valid,
199    /// Key is empty
200    Empty,
201    /// Key is too short
202    TooShort { min_length: usize, actual: usize },
203    /// Key has invalid format
204    InvalidFormat { expected: &'static str },
205    /// Key has invalid prefix
206    InvalidPrefix { expected: &'static str },
207}
208
209impl ApiKeyValidation {
210    /// Check if validation passed
211    pub fn is_valid(&self) -> bool {
212        matches!(self, ApiKeyValidation::Valid)
213    }
214
215    /// Get error message if invalid
216    pub fn error_message(&self) -> Option<String> {
217        match self {
218            ApiKeyValidation::Valid => None,
219            ApiKeyValidation::Empty => Some("API key is empty".to_string()),
220            ApiKeyValidation::TooShort { min_length, actual } => Some(format!(
221                "API key is too short (expected at least {} characters, got {})",
222                min_length, actual
223            )),
224            ApiKeyValidation::InvalidFormat { expected } => {
225                Some(format!("Invalid API key format. Expected: {}", expected))
226            }
227            ApiKeyValidation::InvalidPrefix { expected } => {
228                Some(format!("Invalid API key prefix. Expected: {}", expected))
229            }
230        }
231    }
232}
233
234/// Validate an OpenAI API key
235pub fn validate_openai_key(key: &str) -> ApiKeyValidation {
236    if key.is_empty() {
237        return ApiKeyValidation::Empty;
238    }
239    if key.len() < 20 {
240        return ApiKeyValidation::TooShort {
241            min_length: 20,
242            actual: key.len(),
243        };
244    }
245    if !key.starts_with("sk-") {
246        return ApiKeyValidation::InvalidPrefix { expected: "sk-" };
247    }
248    ApiKeyValidation::Valid
249}
250
251/// Validate an Anthropic API key
252pub fn validate_anthropic_key(key: &str) -> ApiKeyValidation {
253    if key.is_empty() {
254        return ApiKeyValidation::Empty;
255    }
256    if key.len() < 20 {
257        return ApiKeyValidation::TooShort {
258            min_length: 20,
259            actual: key.len(),
260        };
261    }
262    if !key.starts_with("sk-ant-") {
263        return ApiKeyValidation::InvalidPrefix {
264            expected: "sk-ant-",
265        };
266    }
267    ApiKeyValidation::Valid
268}
269
270/// Validate a Grok API key
271pub fn validate_grok_key(key: &str) -> ApiKeyValidation {
272    if key.is_empty() {
273        return ApiKeyValidation::Empty;
274    }
275    if key.len() < 20 {
276        return ApiKeyValidation::TooShort {
277            min_length: 20,
278            actual: key.len(),
279        };
280    }
281    if !key.starts_with("xai-") {
282        return ApiKeyValidation::InvalidPrefix { expected: "xai-" };
283    }
284    ApiKeyValidation::Valid
285}
286
287/// Validate any API key by provider name
288pub fn validate_api_key(provider: &str, key: &str) -> ApiKeyValidation {
289    match provider.to_lowercase().as_str() {
290        "openai" => validate_openai_key(key),
291        "anthropic" => validate_anthropic_key(key),
292        "grok" => validate_grok_key(key),
293        _ => {
294            // Generic validation for unknown providers
295            if key.is_empty() {
296                ApiKeyValidation::Empty
297            } else if key.len() < 10 {
298                ApiKeyValidation::TooShort {
299                    min_length: 10,
300                    actual: key.len(),
301                }
302            } else {
303                ApiKeyValidation::Valid
304            }
305        }
306    }
307}
308
309/// Get all environment variables that are set
310///
311/// Returns a list of (env_var_suffix, display_value, is_secret) tuples
312pub fn get_set_env_vars() -> Vec<(String, String, bool)> {
313    ENV_MAPPINGS
314        .iter()
315        .filter_map(|(suffix, _path, _desc)| {
316            get_env(suffix).map(|value| {
317                let secret = is_secret_key(suffix);
318                let display_value = if secret { redact_secret(&value) } else { value };
319                (suffix.to_string(), display_value, secret)
320            })
321        })
322        .collect()
323}
324
325/// Print documentation for all environment variables
326pub fn print_env_docs() {
327    println!("Gestura Environment Variables");
328    println!("==============================");
329    println!();
330    println!("All environment variables use the {} prefix.", ENV_PREFIX);
331    println!();
332
333    for (suffix, path, desc) in ENV_MAPPINGS {
334        let full_name = format!("{}{}", ENV_PREFIX, suffix);
335        let is_secret = is_secret_key(suffix);
336        let secret_note = if is_secret { " [SECRET]" } else { "" };
337        println!("  {}{}", full_name, secret_note);
338        println!("    Config path: {}", path);
339        println!("    {}", desc);
340        println!();
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_env_prefix() {
350        assert_eq!(ENV_PREFIX, "GESTURA_");
351    }
352
353    #[test]
354    fn test_is_secret_key() {
355        assert!(is_secret_key("OPENAI_API_KEY"));
356        assert!(is_secret_key("api_key"));
357        assert!(is_secret_key("SECRET_TOKEN"));
358        assert!(is_secret_key("password"));
359        assert!(is_secret_key("SERPAPI_KEY"));
360        assert!(!is_secret_key("LLM_PRIMARY"));
361        assert!(!is_secret_key("HOTKEY_LISTEN"));
362        assert!(!is_secret_key("UI_THEME_MODE"));
363    }
364
365    #[test]
366    fn test_redact_secret_short() {
367        assert_eq!(redact_secret("abc"), "***");
368        assert_eq!(redact_secret("12345678"), "***");
369    }
370
371    #[test]
372    fn test_redact_secret_long() {
373        let result = redact_secret("sk-1234567890abcdef");
374        assert!(result.starts_with("sk-1"));
375        assert!(result.ends_with("cdef"));
376        assert!(result.contains("..."));
377    }
378
379    #[test]
380    fn test_get_env_bool_parsing() {
381        // Test the parsing logic directly
382        let parse_bool = |v: &str| -> bool {
383            matches!(
384                v.to_lowercase().as_str(),
385                "true" | "1" | "yes" | "on" | "enabled"
386            )
387        };
388
389        assert!(parse_bool("true"));
390        assert!(parse_bool("TRUE"));
391        assert!(parse_bool("1"));
392        assert!(parse_bool("yes"));
393        assert!(parse_bool("on"));
394        assert!(parse_bool("enabled"));
395        assert!(!parse_bool("false"));
396        assert!(!parse_bool("0"));
397        assert!(!parse_bool("no"));
398        assert!(!parse_bool("off"));
399    }
400
401    #[test]
402    fn test_env_mappings_has_core_settings() {
403        // Should have at least core settings
404        assert!(ENV_MAPPINGS.iter().any(|(k, _, _)| *k == "HOTKEY_LISTEN"));
405        assert!(ENV_MAPPINGS.iter().any(|(k, _, _)| *k == "LLM_PRIMARY"));
406        assert!(ENV_MAPPINGS.iter().any(|(k, _, _)| *k == "OPENAI_API_KEY"));
407        assert!(
408            ENV_MAPPINGS
409                .iter()
410                .any(|(k, _, _)| *k == "ANTHROPIC_API_KEY")
411        );
412        assert!(ENV_MAPPINGS.iter().any(|(k, _, _)| *k == "VOICE_PROVIDER"));
413    }
414
415    #[test]
416    fn test_env_mappings_have_descriptions() {
417        for (suffix, path, desc) in ENV_MAPPINGS {
418            assert!(!suffix.is_empty(), "Suffix should not be empty");
419            assert!(!path.is_empty(), "Path should not be empty for {}", suffix);
420            assert!(
421                !desc.is_empty(),
422                "Description should not be empty for {}",
423                suffix
424            );
425        }
426    }
427
428    #[test]
429    fn test_validate_openai_key() {
430        assert_eq!(validate_openai_key(""), ApiKeyValidation::Empty);
431        assert_eq!(
432            validate_openai_key("short"),
433            ApiKeyValidation::TooShort {
434                min_length: 20,
435                actual: 5
436            }
437        );
438        assert_eq!(
439            validate_openai_key("invalid-key-format-12345"),
440            ApiKeyValidation::InvalidPrefix { expected: "sk-" }
441        );
442        assert_eq!(
443            validate_openai_key("sk-proj-1234567890abcdefgh"),
444            ApiKeyValidation::Valid
445        );
446    }
447
448    #[test]
449    fn test_validate_anthropic_key() {
450        assert_eq!(validate_anthropic_key(""), ApiKeyValidation::Empty);
451        assert_eq!(
452            validate_anthropic_key("sk-12345678901234567890"),
453            ApiKeyValidation::InvalidPrefix {
454                expected: "sk-ant-"
455            }
456        );
457        assert_eq!(
458            validate_anthropic_key("sk-ant-api03-1234567890abcdef"),
459            ApiKeyValidation::Valid
460        );
461    }
462
463    #[test]
464    fn test_validate_grok_key() {
465        assert_eq!(validate_grok_key(""), ApiKeyValidation::Empty);
466        assert_eq!(
467            validate_grok_key("sk-12345678901234567890"),
468            ApiKeyValidation::InvalidPrefix { expected: "xai-" }
469        );
470        assert_eq!(
471            validate_grok_key("xai-1234567890abcdefghij"),
472            ApiKeyValidation::Valid
473        );
474    }
475
476    #[test]
477    fn test_validate_api_key_by_provider() {
478        assert!(validate_api_key("openai", "sk-proj-12345678901234567890").is_valid());
479        assert!(validate_api_key("anthropic", "sk-ant-12345678901234567890").is_valid());
480        assert!(validate_api_key("grok", "xai-12345678901234567890").is_valid());
481        assert!(validate_api_key("unknown", "some-valid-key-here").is_valid());
482        assert!(!validate_api_key("unknown", "short").is_valid());
483    }
484
485    #[test]
486    fn test_api_key_validation_error_messages() {
487        assert!(ApiKeyValidation::Valid.error_message().is_none());
488        assert!(ApiKeyValidation::Empty.error_message().is_some());
489        assert!(
490            ApiKeyValidation::TooShort {
491                min_length: 20,
492                actual: 5
493            }
494            .error_message()
495            .unwrap()
496            .contains("too short")
497        );
498        assert!(
499            ApiKeyValidation::InvalidPrefix { expected: "sk-" }
500                .error_message()
501                .unwrap()
502                .contains("prefix")
503        );
504    }
505}