1use std::env;
11
12pub const ENV_PREFIX: &str = "GESTURA_";
14
15pub 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
21pub 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
31pub fn get_env_u32(key: &str) -> Option<u32> {
33 get_env(key).and_then(|v| v.parse().ok())
34}
35
36pub fn get_env_u64(key: &str) -> Option<u64> {
38 get_env(key).and_then(|v| v.parse().ok())
39}
40
41pub fn get_env_usize(key: &str) -> Option<usize> {
43 get_env(key).and_then(|v| v.parse().ok())
44}
45
46pub const ENV_MAPPINGS: &[(&str, &str, &str)] = &[
50 (
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 (
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 (
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 (
140 "UI_THEME_MODE",
141 "ui.theme_mode",
142 "Theme mode (system, light, dark)",
143 ),
144 ("UI_ACCENT", "ui.accent", "Accent color"),
145 (
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 (
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
175pub 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
185pub 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#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum ApiKeyValidation {
197 Valid,
199 Empty,
201 TooShort { min_length: usize, actual: usize },
203 InvalidFormat { expected: &'static str },
205 InvalidPrefix { expected: &'static str },
207}
208
209impl ApiKeyValidation {
210 pub fn is_valid(&self) -> bool {
212 matches!(self, ApiKeyValidation::Valid)
213 }
214
215 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
234pub 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
251pub 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
270pub 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
287pub 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 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
309pub 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
325pub 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 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 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}