gestura_core/
config.rs

1//! Configuration management for Gestura.
2//!
3//! Core config types (struct definitions, pure helpers) are defined in the
4//! `gestura-core-config` domain crate and re-exported here.  This module
5//! extends them with security-dependent methods via the
6//! [`AppConfigSecurityExt`] extension trait, JSON→YAML migration, and
7//! keychain integration.
8//!
9//! ## Backward compatibility
10//!
11//! Older versions stored configuration as JSON in `~/.gestura/config.json`.
12//! On load, if `config.yaml` does not exist but `config.json` does, we
13//! automatically migrate the JSON file to YAML.
14//!
15//! ## File location and layering
16//!
17//! Gestura's user configuration is centered on `~/.gestura/config.yaml`.
18//! Pure types and validation live in `gestura-core-config`, while this facade
19//! adds the runtime wiring needed to make configuration work in the full app.
20//!
21//! This includes:
22//!
23//! - file-system loading and saving helpers
24//! - JSON-to-YAML migration glue for older installs
25//! - security-aware secret hydration from keychain/secure storage
26//! - canonical-key and legacy-key fallback logic for stored secrets
27//! - user-friendly recovery behavior for fresh installs and reinstalls
28//!
29//! ## Configuration Precedence
30//!
31//! Configuration values are loaded with the following precedence (highest first):
32//! 1. Environment variables (GESTURA_* prefix)
33//! 2. Config file (`~/.gestura/config.yaml`)
34//! 3. Default values
35//!
36//! See [`crate::config_env`] for environment variable documentation.
37//!
38//! ## Secret handling
39//!
40//! Secrets should preferentially live in secure storage rather than plaintext
41//! config files. This module provides the security-aware bridge that can:
42//!
43//! - read from keychain-backed secure storage when available
44//! - fall back to legacy key names during migration
45//! - self-heal secrets into canonical storage keys when possible
46//! - populate the public `AppConfig` model with hydrated runtime values
47//!
48//! Keeping this logic here rather than in `gestura-core-config` preserves a
49//! clean separation between pure config modeling and OS-integrated secret
50//! management.
51
52// All config types from the domain crate are part of this module's public API.
53pub use gestura_core_config::*;
54
55use std::fs;
56use std::path::Path;
57
58// These shadow the glob re-export of the same types from `gestura_core_config::*`
59// (which in turn re-exports from `gestura_core_foundation`). We import from
60// `crate::error` for consistency with other core modules.
61#[allow(hidden_glob_reexports)]
62use crate::error::{AppError, Result};
63
64#[cfg(feature = "security")]
65use crate::security::{create_secure_storage, keychain_access_disabled};
66
67#[cfg(all(feature = "security", not(test)))]
68const KEYCHAIN_SERVICE: &str = "gestura";
69
70#[cfg(all(feature = "security", test))]
71fn test_keychain_store() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
72    use std::collections::HashMap;
73    use std::sync::{Mutex, OnceLock};
74
75    static STORE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
76    STORE.get_or_init(|| Mutex::new(HashMap::new()))
77}
78
79#[cfg(all(feature = "security", test))]
80fn clear_test_keychain_store() {
81    if let Ok(mut m) = test_keychain_store().lock() {
82        m.clear();
83    }
84}
85
86/// Helper to get a key from the keychain (sync wrapper for direct keyring usage)
87#[cfg(all(feature = "security", test))]
88fn get_keychain_secret(key: &str) -> Option<String> {
89    test_keychain_store()
90        .lock()
91        .ok()
92        .and_then(|m| m.get(key).cloned())
93}
94
95#[cfg(all(feature = "security", not(test)))]
96fn get_keychain_secret(key: &str) -> Option<String> {
97    use keyring::Entry;
98    let entry = match Entry::new(KEYCHAIN_SERVICE, key) {
99        Ok(e) => e,
100        Err(e) => {
101            tracing::warn!(
102                key,
103                error = %e,
104                "Failed to create keyring entry — keychain may be inaccessible"
105            );
106            return None;
107        }
108    };
109    match entry.get_password() {
110        Ok(pw) => Some(pw),
111        Err(keyring::Error::NoEntry) => None,
112        Err(e) => {
113            tracing::warn!(
114                key,
115                error = %e,
116                "Failed to read secret from keychain — \
117                 if another app stored this key you may need to grant access \
118                 via the macOS Keychain Access prompt"
119            );
120            None
121        }
122    }
123}
124
125/// Helper to set a key in the keychain (sync wrapper for direct keyring usage)
126#[cfg(all(feature = "security", test))]
127fn set_keychain_secret(key: &str, value: &str) -> Result<()> {
128    test_keychain_store()
129        .lock()
130        .map_err(|_| AppError::Internal("Test keychain poisoned".to_string()))?
131        .insert(key.to_string(), value.to_string());
132    Ok(())
133}
134
135/// Read a secret from secure storage using canonical key names, with legacy-key fallback.
136///
137/// If the secret is found under the legacy key, this will *best-effort* re-store
138/// it under the canonical key (self-heal migration) so future reads converge.
139#[cfg(feature = "security")]
140fn get_keychain_secret_with_legacy_fallback(
141    canonical_key: &str,
142    legacy_key: &str,
143) -> Option<String> {
144    if let Some(v) = get_keychain_secret(canonical_key) {
145        return (!v.is_empty()).then_some(v);
146    }
147
148    let legacy = get_keychain_secret(legacy_key)?;
149    if legacy.is_empty() {
150        return None;
151    }
152
153    if let Err(e) = set_keychain_secret(canonical_key, &legacy) {
154        tracing::warn!(
155            canonical_key,
156            legacy_key,
157            error = %e,
158            "Failed to self-heal secret from legacy key to canonical key"
159        );
160    }
161
162    Some(legacy)
163}
164
165/// Async secure-storage secret read with canonical-key preference and legacy fallback.
166///
167/// If the secret is found under the legacy key, this best-effort self-heals by
168/// copying it to the canonical key.
169#[cfg(feature = "security")]
170async fn get_secret_with_legacy(
171    storage: &dyn crate::security::SecureStorage,
172    canonical_key: &str,
173    legacy_key: &str,
174) -> Option<String> {
175    if let Ok(Some(v)) = storage.get_secret(canonical_key).await
176        && !v.is_empty()
177    {
178        return Some(v);
179    }
180
181    if let Ok(Some(v)) = storage.get_secret(legacy_key).await
182        && !v.is_empty()
183    {
184        // Best-effort convergence to canonical key name.
185        let _ = storage.store_secret(canonical_key, &v).await;
186        return Some(v);
187    }
188
189    None
190}
191
192/// Fresh-install UX: If we loaded defaults (no YAML/legacy JSON) and the default
193/// primary provider is unconfigured, pick a usable primary provider based on
194/// hydrated keychain secrets.
195///
196/// This avoids the confusing "provider not configured" state after reinstall
197/// when API tokens still exist in the OS keychain.
198#[cfg(feature = "security")]
199fn autoselect_primary_llm_provider_from_hydrated_secrets(config: &mut AppConfig) {
200    fn provider_is_configured(llm: &LlmSettings, provider: &str) -> bool {
201        if provider.eq_ignore_ascii_case("openai") {
202            llm.openai
203                .as_ref()
204                .is_some_and(|c| !c.api_key.trim().is_empty())
205        } else if provider.eq_ignore_ascii_case("anthropic") {
206            llm.anthropic
207                .as_ref()
208                .is_some_and(|c| !c.api_key.trim().is_empty())
209        } else if provider.eq_ignore_ascii_case("gemini") {
210            llm.gemini
211                .as_ref()
212                .is_some_and(|c| !c.api_key.trim().is_empty())
213        } else if provider.eq_ignore_ascii_case("grok") {
214            llm.grok
215                .as_ref()
216                .is_some_and(|c| !c.api_key.trim().is_empty())
217        } else if provider.eq_ignore_ascii_case("ollama") {
218            // Local provider; does not require an API key.
219            true
220        } else {
221            false
222        }
223    }
224
225    // Keep the user's chosen primary if it's already configured.
226    if provider_is_configured(&config.llm, &config.llm.primary) {
227        return;
228    }
229
230    // Prefer any configured cloud provider, otherwise choose ollama.
231    for candidate in ["anthropic", "openai", "gemini", "grok"] {
232        if provider_is_configured(&config.llm, candidate) {
233            config.llm.primary = candidate.to_string();
234            return;
235        }
236    }
237
238    config.llm.primary = "ollama".to_string();
239}
240
241/// Returns true if secure storage contains a non-empty secret under either the
242/// canonical or legacy key.
243///
244/// Note: this uses the same legacy-fallback + self-heal behavior as reads.
245#[cfg(feature = "security")]
246fn keychain_has_secret(canonical_key: &str, legacy_key: &str) -> bool {
247    get_keychain_secret_with_legacy_fallback(canonical_key, legacy_key).is_some()
248}
249
250#[cfg(all(feature = "security", not(test)))]
251fn set_keychain_secret(key: &str, value: &str) -> Result<()> {
252    use keyring::Entry;
253    Entry::new(KEYCHAIN_SERVICE, key)
254        .map_err(|e| AppError::Internal(format!("Keyring error: {}", e)))?
255        .set_password(value)
256        .map_err(|e| AppError::Internal(format!("Keyring error: {}", e)))?;
257    Ok(())
258}
259// ---------------------------------------------------------------------------
260// Extension trait: security-dependent methods for AppConfig
261// ---------------------------------------------------------------------------
262
263/// Extension trait adding security-dependent methods to [`AppConfig`].
264///
265/// Import this trait (or `use crate::config::*`) to access `load()`, `save()`,
266/// keychain hydration, and secret migration methods on [`AppConfig`].
267pub trait AppConfigSecurityExt: Sized {
268    /// Load configuration from disk, falling back to defaults if missing (sync).
269    fn load() -> Self;
270    /// Load configuration from disk asynchronously.
271    fn load_async() -> impl std::future::Future<Output = Self> + Send;
272    /// Save configuration to disk at an explicit path.
273    fn save_to_path(&self, path: impl AsRef<Path>) -> Result<()>;
274    /// Save configuration to disk (sync).
275    fn save(&self) -> Result<()>;
276    /// Save configuration to disk asynchronously.
277    fn save_async(&self) -> impl std::future::Future<Output = Result<()>> + Send;
278    /// Save configuration to disk at an explicit path (async).
279    fn save_to_path_async(
280        &self,
281        path: impl AsRef<Path> + Send,
282    ) -> impl std::future::Future<Output = Result<()>> + Send;
283    /// Load configuration with environment variable overrides applied.
284    fn load_with_env() -> Self;
285    /// Load configuration asynchronously with environment variable overrides.
286    fn load_with_env_async() -> impl std::future::Future<Output = Self> + Send;
287    /// Clear secrets from the struct (used before saving to disk).
288    #[cfg(feature = "security")]
289    fn sanitize_secrets(&mut self);
290    /// Check which API key providers have secrets stored in the OS keychain.
291    fn api_key_keychain_status() -> Vec<(&'static str, bool)>;
292    /// Returns true if the config struct currently contains any plaintext secrets.
293    #[cfg(feature = "security")]
294    fn has_plaintext_secrets(&self) -> bool;
295    /// Load secrets from keystore into the struct (sync).
296    #[cfg(feature = "security")]
297    fn hydrate_secrets_sync(&mut self) -> Result<()>;
298    /// Async version of hydrate secrets.
299    #[cfg(feature = "security")]
300    fn hydrate_secrets(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
301    /// Move secrets from the struct (if present) to the keychain (sync).
302    #[cfg(feature = "security")]
303    fn migrate_secrets_sync(&self) -> Result<bool>;
304    /// Async version of migrate secrets.
305    #[cfg(feature = "security")]
306    fn migrate_secrets(&self) -> impl std::future::Future<Output = Result<bool>> + Send;
307}
308
309impl AppConfigSecurityExt for AppConfig {
310    /// Load configuration from disk, falling back to defaults if missing (sync version).
311    ///
312    /// If `~/.gestura/config.yaml` is missing but `~/.gestura/config.json` exists,
313    /// this will automatically migrate the JSON file to YAML.
314    ///
315    /// This method also handles migration of secrets to the secure keystore.
316    fn load() -> Self {
317        let yaml_path = Self::default_path();
318        let json_path = Self::legacy_json_path();
319        let backup_path = Self::legacy_json_backup_path();
320        let had_yaml = yaml_path.exists();
321        let had_json = json_path.exists();
322        #[cfg_attr(not(feature = "security"), allow(unused_variables))]
323        let used_default_config = !had_yaml && !had_json;
324        // Capture the initial state so we can decide whether to perform JSON->YAML
325        // migration even if later steps (e.g., secret hydration) create the YAML.
326        let needs_format_migration = !had_yaml && had_json;
327        #[allow(unused_mut)] // config is mutated by hydrate_secrets/migrate_secrets
328        let mut config = if had_yaml {
329            Self::load_from_path(&yaml_path)
330        } else {
331            // Check for legacy JSON
332            if had_json {
333                if let Ok(s) = fs::read_to_string(&json_path) {
334                    // We found JSON but no YAML. We will return this config.
335                    // Persisting (format migration) happens below.
336                    serde_json::from_str::<Self>(&s).unwrap_or_default()
337                } else {
338                    Self::default()
339                }
340            } else {
341                Self::default()
342            }
343        };
344        config.normalize_derived_defaults();
345
346        // Hydrate secrets from keychain (if empty in file)
347        #[cfg(feature = "security")]
348        {
349            if keychain_access_disabled() {
350                // In non-interactive contexts (CI/integration tests), keychain access can block.
351                // When explicitly disabled, skip hydration/migration to avoid hangs and avoid
352                // accidentally sanitizing secrets without a secure keystore destination.
353                tracing::info!(
354                    "Keychain access disabled; skipping secret hydration/migration on config load"
355                );
356            } else {
357                // Detect plaintext secrets in the loaded config *before* hydration.
358                // If they exist, we must sanitize persisted YAML even when keychain already has keys.
359                let had_plaintext_secrets = config.has_plaintext_secrets();
360
361                // Keychain-first: if secure storage has secrets, they override YAML values.
362                let _ = config.hydrate_secrets_sync();
363
364                // Fresh installs often have no config file but do have secrets in the keychain.
365                // If the default primary provider is unconfigured, but another provider has a
366                // configured key, switch primary in-memory to avoid the confusing
367                // "provider not configured" experience.
368                if used_default_config {
369                    autoselect_primary_llm_provider_from_hydrated_secrets(&mut config);
370                }
371
372                // Migrate YAML secrets into secure storage only when secure storage is empty.
373                let migrated = config.migrate_secrets_sync().unwrap_or(false);
374
375                // If we either migrated secrets OR we detected plaintext secrets in the file,
376                // persist a sanitized config back to disk.
377                if had_plaintext_secrets || migrated {
378                    let _ = config.save();
379                }
380            }
381        }
382
383        // JSON -> YAML format migration should occur regardless of whether the `security` feature
384        // is enabled. In security builds, `save()` already refuses to persist plaintext secrets
385        // when keychain access is disabled, which prevents accidental data loss.
386        if needs_format_migration {
387            let _ = config.save();
388            // Only move the legacy JSON aside if we successfully created YAML.
389            if yaml_path.exists() && json_path.exists() && !backup_path.exists() {
390                let _ = fs::rename(&json_path, &backup_path);
391            }
392        }
393
394        // Aggregate servers from `.mcp.json` scopes and add them to `config.mcp_servers`
395        let mcp_json_servers = crate::mcp::config::McpJsonFile::load_aggregated();
396        for server in mcp_json_servers {
397            // Overwrite existing entries from config.yaml if they share the same name.
398            if let Some(existing) = config
399                .mcp_servers
400                .iter_mut()
401                .find(|s| s.name == server.name)
402            {
403                *existing = server;
404            } else {
405                config.mcp_servers.push(server);
406            }
407        }
408
409        config
410    }
411
412    /// Load configuration from disk asynchronously, falling back to defaults if missing.
413    ///
414    /// This is the preferred method for GUI/Tauri commands to avoid blocking the UI thread.
415    async fn load_async() -> Self {
416        let yaml_path = Self::default_path();
417        let json_path = Self::legacy_json_path();
418        let backup_path = Self::legacy_json_backup_path();
419        let had_yaml = tokio::fs::try_exists(&yaml_path).await.unwrap_or(false);
420        let had_json = tokio::fs::try_exists(&json_path).await.unwrap_or(false);
421        #[cfg_attr(not(feature = "security"), allow(unused_variables))]
422        let used_default_config = !had_yaml && !had_json;
423        let needs_format_migration = !had_yaml && had_json;
424        #[allow(unused_mut)] // config is mutated by hydrate_secrets/migrate_secrets
425        let mut config = if had_yaml {
426            Self::load_from_path_async(&yaml_path).await
427        } else if had_json {
428            if let Ok(s) = tokio::fs::read_to_string(&json_path).await {
429                serde_json::from_str::<Self>(&s).unwrap_or_default()
430            } else {
431                Self::default()
432            }
433        } else {
434            Self::default()
435        };
436        config.normalize_derived_defaults();
437
438        // Async hydration/migration
439        #[cfg(feature = "security")]
440        {
441            if keychain_access_disabled() {
442                tracing::info!(
443                    "Keychain access disabled; skipping secret hydration/migration on async config load"
444                );
445            } else {
446                let had_plaintext_secrets = config.has_plaintext_secrets();
447
448                // Keychain-first precedence.
449                let _ = config.hydrate_secrets().await;
450
451                if used_default_config {
452                    autoselect_primary_llm_provider_from_hydrated_secrets(&mut config);
453                }
454
455                // Migrate YAML secrets into secure storage only if secure storage is empty.
456                let migrated = config.migrate_secrets().await.unwrap_or(false);
457
458                if had_plaintext_secrets || migrated {
459                    let _ = config.save_async().await;
460                }
461            }
462        }
463
464        // JSON -> YAML format migration should occur regardless of whether the `security` feature
465        // is enabled. In security builds, `save_async()` already refuses to persist plaintext
466        // secrets when keychain access is disabled.
467        if needs_format_migration {
468            let _ = config.save_async().await;
469
470            let yaml_exists = tokio::fs::try_exists(&yaml_path).await.unwrap_or(false);
471            let json_exists = tokio::fs::try_exists(&json_path).await.unwrap_or(false);
472            let backup_exists = tokio::fs::try_exists(&backup_path).await.unwrap_or(false);
473
474            if yaml_exists && json_exists && !backup_exists {
475                let _ = tokio::fs::rename(&json_path, &backup_path).await;
476            }
477        }
478
479        // Aggregate servers from `.mcp.json` scopes and add them to `config.mcp_servers`
480        let mcp_json_servers = crate::mcp::config::McpJsonFile::load_aggregated_async().await;
481        for server in mcp_json_servers {
482            // Overwrite existing entries from config.yaml if they share the same name.
483            if let Some(existing) = config
484                .mcp_servers
485                .iter_mut()
486                .find(|s| s.name == server.name)
487            {
488                *existing = server;
489            } else {
490                config.mcp_servers.push(server);
491            }
492        }
493
494        config
495    }
496
497    /// Save configuration to disk at an explicit path.
498    ///
499    /// This handles stripping secrets before writing to disk if security is enabled.
500    fn save_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
501        let path = path.as_ref();
502        if let Some(parent) = path.parent() {
503            fs::create_dir_all(parent)?;
504        }
505
506        #[cfg(feature = "security")]
507        {
508            if keychain_access_disabled() && self.has_plaintext_secrets() {
509                return Err(AppError::Config(
510                    "Cannot persist plaintext secrets while keychain access is disabled. \
511Set secrets via environment variables or re-enable keychain access (unset GESTURA_DISABLE_KEYCHAIN)."
512                        .to_string(),
513                ));
514            }
515
516            // First, ensure all current secrets are saved to keystore
517            let _ = self.migrate_secrets_sync();
518
519            let mut clean_config = self.clone();
520            clean_config.sanitize_secrets();
521            let data = serde_yaml::to_string(&clean_config)
522                .map_err(|e| AppError::Config(format!("Failed to serialize config: {}", e)))?;
523            fs::write(path, data)?;
524        }
525
526        #[cfg(not(feature = "security"))]
527        {
528            let data = serde_yaml::to_string(self)
529                .map_err(|e| AppError::Config(format!("Failed to serialize config: {}", e)))?;
530            fs::write(path, data)?;
531        }
532
533        Ok(())
534    }
535
536    /// Save configuration to disk (sync version).
537    fn save(&self) -> Result<()> {
538        self.save_to_path(Self::default_path())
539    }
540
541    /// Save configuration to disk asynchronously.
542    async fn save_async(&self) -> Result<()> {
543        self.save_to_path_async(Self::default_path()).await
544    }
545
546    /// Save configuration to disk at an explicit path (async).
547    ///
548    /// This is the async equivalent of [`AppConfig::save_to_path`].
549    async fn save_to_path_async(&self, path: impl AsRef<Path>) -> Result<()> {
550        let path = path.as_ref().to_path_buf();
551        if let Some(parent) = path.parent() {
552            tokio::fs::create_dir_all(parent).await?;
553        }
554
555        #[cfg(feature = "security")]
556        {
557            if keychain_access_disabled() && self.has_plaintext_secrets() {
558                return Err(AppError::Config(
559                    "Cannot persist plaintext secrets while keychain access is disabled. \
560Set secrets via environment variables or re-enable keychain access (unset GESTURA_DISABLE_KEYCHAIN)."
561                        .to_string(),
562                ));
563            }
564
565            // First, ensure all current secrets are saved to keystore
566            // We ignore the "changed" boolean here as we just want to ensure consistency
567            let _ = self.migrate_secrets().await;
568
569            let mut clean_config = self.clone();
570            clean_config.sanitize_secrets();
571            let data = serde_yaml::to_string(&clean_config)
572                .map_err(|e| AppError::Config(format!("Failed to serialize config: {}", e)))?;
573            tokio::fs::write(path, data).await?;
574        }
575
576        #[cfg(not(feature = "security"))]
577        {
578            let data = serde_yaml::to_string(self)
579                .map_err(|e| AppError::Config(format!("Failed to serialize config: {}", e)))?;
580            tokio::fs::write(path, data).await?;
581        }
582
583        Ok(())
584    }
585
586    // ========================================================================
587    // Security Helpers
588    // ========================================================================
589
590    /// Clear secrets from the struct (used before saving to disk)
591    #[cfg(feature = "security")]
592    fn sanitize_secrets(&mut self) {
593        if let Some(c) = &mut self.llm.openai {
594            c.api_key.clear();
595        }
596        if let Some(c) = &mut self.llm.anthropic {
597            c.api_key.clear();
598        }
599        if let Some(c) = &mut self.llm.grok {
600            c.api_key.clear();
601        }
602        if let Some(c) = &mut self.llm.gemini {
603            c.api_key.clear();
604        }
605
606        self.voice.openai_api_key = None;
607        self.web_search.serpapi_key = None;
608        self.web_search.brave_key = None;
609    }
610
611    /// Check which API key providers have secrets stored in the OS keychain.
612    ///
613    /// Returns a list of `(provider_label, is_present)` tuples for every known
614    /// provider.  This is intended for CLI/TUI display — no secret values are
615    /// exposed.
616    ///
617    /// When the `security` feature is disabled (or keychain access is disabled at
618    /// runtime) every provider reports `false`.
619    #[cfg(feature = "security")]
620    fn api_key_keychain_status() -> Vec<(&'static str, bool)> {
621        if keychain_access_disabled() {
622            return vec![
623                ("openai", false),
624                ("anthropic", false),
625                ("gemini", false),
626                ("grok", false),
627                ("voice_openai", false),
628                ("serpapi", false),
629                ("brave", false),
630            ];
631        }
632
633        vec![
634            (
635                "openai",
636                keychain_has_secret("gestura_llm_openai_api_key", "gestura_api_key_openai"),
637            ),
638            (
639                "anthropic",
640                keychain_has_secret("gestura_llm_anthropic_api_key", "gestura_api_key_anthropic"),
641            ),
642            (
643                "gemini",
644                keychain_has_secret("gestura_llm_gemini_api_key", "gestura_api_key_gemini"),
645            ),
646            (
647                "grok",
648                keychain_has_secret("gestura_llm_grok_api_key", "gestura_api_key_grok"),
649            ),
650            (
651                "voice_openai",
652                keychain_has_secret(
653                    "gestura_voice_openai_api_key",
654                    "gestura_api_key_voice_openai",
655                ),
656            ),
657            (
658                "serpapi",
659                keychain_has_secret("gestura_web_search_serpapi_key", "gestura_api_key_serpapi"),
660            ),
661            (
662                "brave",
663                keychain_has_secret("gestura_web_search_brave_key", "gestura_api_key_brave"),
664            ),
665        ]
666    }
667
668    /// Convenience: returns `false` for all providers when the `security` feature
669    /// is not compiled in.
670    #[cfg(not(feature = "security"))]
671    fn api_key_keychain_status() -> Vec<(&'static str, bool)> {
672        vec![
673            ("openai", false),
674            ("anthropic", false),
675            ("gemini", false),
676            ("grok", false),
677            ("voice_openai", false),
678            ("serpapi", false),
679            ("brave", false),
680        ]
681    }
682
683    /// Returns true if the config struct currently contains any plaintext secrets
684    /// that should not be persisted to disk.
685    #[cfg(feature = "security")]
686    fn has_plaintext_secrets(&self) -> bool {
687        self.llm
688            .openai
689            .as_ref()
690            .is_some_and(|c| !c.api_key.is_empty())
691            || self
692                .llm
693                .anthropic
694                .as_ref()
695                .is_some_and(|c| !c.api_key.is_empty())
696            || self
697                .llm
698                .grok
699                .as_ref()
700                .is_some_and(|c| !c.api_key.is_empty())
701            || self
702                .llm
703                .gemini
704                .as_ref()
705                .is_some_and(|c| !c.api_key.is_empty())
706            || self
707                .voice
708                .openai_api_key
709                .as_deref()
710                .is_some_and(|k| !k.is_empty())
711            || self
712                .web_search
713                .serpapi_key
714                .as_deref()
715                .is_some_and(|k| !k.is_empty())
716            || self
717                .web_search
718                .brave_key
719                .as_deref()
720                .is_some_and(|k| !k.is_empty())
721    }
722
723    /// Load secrets from keystore into the struct (sync)
724    #[cfg(feature = "security")]
725    fn hydrate_secrets_sync(&mut self) -> Result<()> {
726        if keychain_access_disabled() {
727            return Ok(());
728        }
729
730        // OpenAI
731        if let Some(secret) = get_keychain_secret_with_legacy_fallback(
732            "gestura_llm_openai_api_key",
733            "gestura_api_key_openai",
734        ) {
735            // Keychain-first: overwrite YAML value if a key exists in secure storage.
736            let c = self.llm.openai.get_or_insert_with(Default::default);
737            c.api_key = secret;
738        }
739        // Belt-and-suspenders: back-fill empty model on pre-existing configs.
740        if let Some(c) = self.llm.openai.as_mut()
741            && c.model.is_empty()
742        {
743            c.model = crate::default_models::DEFAULT_OPENAI_MODEL.to_string();
744        }
745
746        // Anthropic
747        if let Some(secret) = get_keychain_secret_with_legacy_fallback(
748            "gestura_llm_anthropic_api_key",
749            "gestura_api_key_anthropic",
750        ) {
751            let c = self.llm.anthropic.get_or_insert_with(Default::default);
752            c.api_key = secret;
753        }
754        if let Some(c) = self.llm.anthropic.as_mut()
755            && c.model.is_empty()
756        {
757            c.model = crate::default_models::DEFAULT_ANTHROPIC_MODEL.to_string();
758        }
759
760        // Grok
761        if let Some(secret) = get_keychain_secret_with_legacy_fallback(
762            "gestura_llm_grok_api_key",
763            "gestura_api_key_grok",
764        ) {
765            let c = self.llm.grok.get_or_insert_with(Default::default);
766            c.api_key = secret;
767        }
768        if let Some(c) = self.llm.grok.as_mut()
769            && c.model.is_empty()
770        {
771            c.model = crate::default_models::DEFAULT_GROK_MODEL.to_string();
772        }
773
774        // Gemini
775        if let Some(secret) = get_keychain_secret_with_legacy_fallback(
776            "gestura_llm_gemini_api_key",
777            "gestura_api_key_gemini",
778        ) {
779            let c = self.llm.gemini.get_or_insert_with(Default::default);
780            c.api_key = secret;
781        }
782        if let Some(c) = self.llm.gemini.as_mut()
783            && c.model.is_empty()
784        {
785            c.model = crate::default_models::DEFAULT_GEMINI_MODEL.to_string();
786        }
787
788        // Voice OpenAI
789        if let Some(secret) = get_keychain_secret_with_legacy_fallback(
790            "gestura_voice_openai_api_key",
791            "gestura_api_key_voice_openai",
792        ) {
793            self.voice.openai_api_key = Some(secret);
794        }
795
796        // SerpAPI
797        if let Some(secret) = get_keychain_secret_with_legacy_fallback(
798            "gestura_web_search_serpapi_key",
799            "gestura_api_key_serpapi",
800        ) {
801            self.web_search.serpapi_key = Some(secret);
802        }
803
804        // Brave
805        if let Some(secret) = get_keychain_secret_with_legacy_fallback(
806            "gestura_web_search_brave_key",
807            "gestura_api_key_brave",
808        ) {
809            self.web_search.brave_key = Some(secret);
810        }
811
812        Ok(())
813    }
814
815    /// Async version of hydrate secrets
816    #[cfg(feature = "security")]
817    async fn hydrate_secrets(&mut self) -> Result<()> {
818        if keychain_access_disabled() {
819            return Ok(());
820        }
821
822        let storage = create_secure_storage();
823
824        // Helper macro to reduce boilerplate.
825        // Tries canonical key first, then legacy key; if legacy is found, re-stores
826        // under canonical key (best-effort) to converge.
827        macro_rules! hydrate_with_legacy {
828            ($field:expr, $canonical:expr, $legacy:expr) => {
829                // Keychain-first: overwrite YAML value if secure storage has a secret.
830                let mut found: Option<String> = None;
831                if let Ok(Some(secret)) = storage.get_secret($canonical).await {
832                    if !secret.is_empty() {
833                        found = Some(secret);
834                    }
835                }
836                if found.is_none() {
837                    if let Ok(Some(secret)) = storage.get_secret($legacy).await {
838                        if !secret.is_empty() {
839                            let _ = storage.store_secret($canonical, &secret).await;
840                            found = Some(secret);
841                        }
842                    }
843                }
844                if let Some(secret) = found {
845                    *$field = secret;
846                }
847            };
848            ($field:expr, $canonical:expr, $legacy:expr, option) => {
849                let mut found: Option<String> = None;
850                if let Ok(Some(secret)) = storage.get_secret($canonical).await {
851                    if !secret.is_empty() {
852                        found = Some(secret);
853                    }
854                }
855                if found.is_none() {
856                    if let Ok(Some(secret)) = storage.get_secret($legacy).await {
857                        if !secret.is_empty() {
858                            let _ = storage.store_secret($canonical, &secret).await;
859                            found = Some(secret);
860                        }
861                    }
862                }
863                if let Some(secret) = found {
864                    *$field = Some(secret);
865                }
866            };
867        }
868
869        // LLM providers: if the key exists in secure storage, bootstrap the provider config
870        // object even when it is missing from YAML (common on fresh installs).
871        if let Some(secret) = get_secret_with_legacy(
872            storage.as_ref(),
873            "gestura_llm_openai_api_key",
874            "gestura_api_key_openai",
875        )
876        .await
877        {
878            let c = self.llm.openai.get_or_insert_with(Default::default);
879            c.api_key = secret;
880        }
881        // Belt-and-suspenders: back-fill empty model on pre-existing configs.
882        if let Some(c) = self.llm.openai.as_mut()
883            && c.model.is_empty()
884        {
885            c.model = crate::default_models::DEFAULT_OPENAI_MODEL.to_string();
886        }
887        if let Some(secret) = get_secret_with_legacy(
888            storage.as_ref(),
889            "gestura_llm_anthropic_api_key",
890            "gestura_api_key_anthropic",
891        )
892        .await
893        {
894            let c = self.llm.anthropic.get_or_insert_with(Default::default);
895            c.api_key = secret;
896        }
897        if let Some(c) = self.llm.anthropic.as_mut()
898            && c.model.is_empty()
899        {
900            c.model = crate::default_models::DEFAULT_ANTHROPIC_MODEL.to_string();
901        }
902        if let Some(secret) = get_secret_with_legacy(
903            storage.as_ref(),
904            "gestura_llm_grok_api_key",
905            "gestura_api_key_grok",
906        )
907        .await
908        {
909            let c = self.llm.grok.get_or_insert_with(Default::default);
910            c.api_key = secret;
911        }
912        if let Some(c) = self.llm.grok.as_mut()
913            && c.model.is_empty()
914        {
915            c.model = crate::default_models::DEFAULT_GROK_MODEL.to_string();
916        }
917        if let Some(secret) = get_secret_with_legacy(
918            storage.as_ref(),
919            "gestura_llm_gemini_api_key",
920            "gestura_api_key_gemini",
921        )
922        .await
923        {
924            let c = self.llm.gemini.get_or_insert_with(Default::default);
925            c.api_key = secret;
926        }
927        if let Some(c) = self.llm.gemini.as_mut()
928            && c.model.is_empty()
929        {
930            c.model = crate::default_models::DEFAULT_GEMINI_MODEL.to_string();
931        }
932
933        hydrate_with_legacy!(
934            &mut self.voice.openai_api_key,
935            "gestura_voice_openai_api_key",
936            "gestura_api_key_voice_openai",
937            option
938        );
939        hydrate_with_legacy!(
940            &mut self.web_search.serpapi_key,
941            "gestura_web_search_serpapi_key",
942            "gestura_api_key_serpapi",
943            option
944        );
945        hydrate_with_legacy!(
946            &mut self.web_search.brave_key,
947            "gestura_web_search_brave_key",
948            "gestura_api_key_brave",
949            option
950        );
951
952        Ok(())
953    }
954
955    /// Move secrets from the struct (if present) to the keychain.
956    /// Returns true if any secrets were migrated.
957    #[cfg(feature = "security")]
958    fn migrate_secrets_sync(&self) -> Result<bool> {
959        if keychain_access_disabled() {
960            return Ok(false);
961        }
962
963        let mut changed = false;
964
965        // OpenAI
966        if let Some(c) = &self.llm.openai
967            && !c.api_key.is_empty()
968        {
969            // Do not overwrite an existing keychain secret.
970            if !keychain_has_secret("gestura_llm_openai_api_key", "gestura_api_key_openai") {
971                set_keychain_secret("gestura_llm_openai_api_key", &c.api_key)?;
972                changed = true;
973            }
974            // Note: We do NOT clear the key here, because we want it available in memory.
975            // It will be cleared when save() calls sanitize_secrets().
976        }
977
978        // Anthropic
979        if let Some(c) = &self.llm.anthropic
980            && !c.api_key.is_empty()
981            && !keychain_has_secret("gestura_llm_anthropic_api_key", "gestura_api_key_anthropic")
982        {
983            set_keychain_secret("gestura_llm_anthropic_api_key", &c.api_key)?;
984            changed = true;
985        }
986
987        // Grok
988        if let Some(c) = &self.llm.grok
989            && !c.api_key.is_empty()
990            && !keychain_has_secret("gestura_llm_grok_api_key", "gestura_api_key_grok")
991        {
992            set_keychain_secret("gestura_llm_grok_api_key", &c.api_key)?;
993            changed = true;
994        }
995
996        // Gemini
997        if let Some(c) = &self.llm.gemini
998            && !c.api_key.is_empty()
999            && !keychain_has_secret("gestura_llm_gemini_api_key", "gestura_api_key_gemini")
1000        {
1001            set_keychain_secret("gestura_llm_gemini_api_key", &c.api_key)?;
1002            changed = true;
1003        }
1004
1005        // Voice
1006        if let Some(key) = self.voice.openai_api_key.as_deref()
1007            && !key.is_empty()
1008            && !keychain_has_secret(
1009                "gestura_voice_openai_api_key",
1010                "gestura_api_key_voice_openai",
1011            )
1012        {
1013            set_keychain_secret("gestura_voice_openai_api_key", key)?;
1014            changed = true;
1015        }
1016
1017        // SerpAPI
1018        if let Some(key) = self.web_search.serpapi_key.as_deref()
1019            && !key.is_empty()
1020            && !keychain_has_secret("gestura_web_search_serpapi_key", "gestura_api_key_serpapi")
1021        {
1022            set_keychain_secret("gestura_web_search_serpapi_key", key)?;
1023            changed = true;
1024        }
1025
1026        // Brave
1027        if let Some(key) = self.web_search.brave_key.as_deref()
1028            && !key.is_empty()
1029            && !keychain_has_secret("gestura_web_search_brave_key", "gestura_api_key_brave")
1030        {
1031            set_keychain_secret("gestura_web_search_brave_key", key)?;
1032            changed = true;
1033        }
1034
1035        Ok(changed)
1036    }
1037
1038    /// Async version of migrate secrets
1039    #[cfg(feature = "security")]
1040    async fn migrate_secrets(&self) -> Result<bool> {
1041        if keychain_access_disabled() {
1042            return Ok(false);
1043        }
1044
1045        let storage = create_secure_storage();
1046        let mut changed = false;
1047
1048        async fn storage_has_secret(
1049            storage: &dyn crate::security::SecureStorage,
1050            canonical: &str,
1051            legacy: &str,
1052        ) -> bool {
1053            // Prefer canonical
1054            if let Ok(Some(v)) = storage.get_secret(canonical).await
1055                && !v.is_empty()
1056            {
1057                return true;
1058            }
1059            // Fallback legacy + self-heal
1060            if let Ok(Some(v)) = storage.get_secret(legacy).await
1061                && !v.is_empty()
1062            {
1063                let _ = storage.store_secret(canonical, &v).await;
1064                return true;
1065            }
1066            false
1067        }
1068
1069        macro_rules! migrate {
1070            ($field:expr, $canonical:expr, $legacy:expr) => {
1071                if !$field.is_empty() {
1072                    if !storage_has_secret(storage.as_ref(), $canonical, $legacy).await {
1073                        let _ = storage.store_secret($canonical, $field).await;
1074                        changed = true;
1075                    }
1076                }
1077            };
1078            ($field:expr, $canonical:expr, $legacy:expr, option) => {
1079                if let Some(val) = $field {
1080                    if !val.is_empty() {
1081                        if !storage_has_secret(storage.as_ref(), $canonical, $legacy).await {
1082                            let _ = storage.store_secret($canonical, val).await;
1083                            changed = true;
1084                        }
1085                    }
1086                }
1087            };
1088        }
1089
1090        if let Some(c) = &self.llm.openai {
1091            migrate!(
1092                &c.api_key,
1093                "gestura_llm_openai_api_key",
1094                "gestura_api_key_openai"
1095            );
1096        }
1097        if let Some(c) = &self.llm.anthropic {
1098            migrate!(
1099                &c.api_key,
1100                "gestura_llm_anthropic_api_key",
1101                "gestura_api_key_anthropic"
1102            );
1103        }
1104        if let Some(c) = &self.llm.grok {
1105            migrate!(
1106                &c.api_key,
1107                "gestura_llm_grok_api_key",
1108                "gestura_api_key_grok"
1109            );
1110        }
1111        if let Some(c) = &self.llm.gemini {
1112            migrate!(
1113                &c.api_key,
1114                "gestura_llm_gemini_api_key",
1115                "gestura_api_key_gemini"
1116            );
1117        }
1118
1119        migrate!(
1120            &self.voice.openai_api_key,
1121            "gestura_voice_openai_api_key",
1122            "gestura_api_key_voice_openai",
1123            option
1124        );
1125        migrate!(
1126            &self.web_search.serpapi_key,
1127            "gestura_web_search_serpapi_key",
1128            "gestura_api_key_serpapi",
1129            option
1130        );
1131        migrate!(
1132            &self.web_search.brave_key,
1133            "gestura_web_search_brave_key",
1134            "gestura_api_key_brave",
1135            option
1136        );
1137
1138        Ok(changed)
1139    }
1140
1141    /// Load configuration with environment variable overrides applied
1142    ///
1143    /// This is the recommended way to load configuration as it respects
1144    /// the full precedence hierarchy: env vars > config file > defaults
1145    fn load_with_env() -> Self {
1146        Self::load().apply_env_overrides()
1147    }
1148
1149    /// Load configuration asynchronously with environment variable overrides
1150    async fn load_with_env_async() -> Self {
1151        Self::load_async().await.apply_env_overrides()
1152    }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158    use crate::pipeline::CompactionStrategy;
1159    use std::sync::{Mutex, OnceLock};
1160
1161    #[test]
1162    fn default_config_has_expected_values() {
1163        let c = AppConfig::default();
1164        assert_eq!(c.hotkey_listen, "Ctrl+Space");
1165        assert_eq!(c.grace_period_secs, 30);
1166        assert_eq!(c.llm.primary, "anthropic");
1167        assert!(!c.privacy.data_collection);
1168        assert!(c.privacy.crash_reports);
1169        assert!(c.privacy.voice_data_local);
1170        assert!(!c.privacy.require_auth);
1171        assert_eq!(c.privacy.auth_timeout, 15);
1172        // Clean-config: no pre-populated fallback or provider blocks in defaults;
1173        // optional fields are None until the user explicitly configures them.
1174        assert_eq!(c.llm.fallback, None);
1175        assert!(c.llm.ollama.is_none());
1176        assert!(c.llm.openai.is_none());
1177        assert!(c.llm.anthropic.is_none());
1178        // OllamaConfig::default() still has sensible values for when it IS selected.
1179        let ollama_defaults = OllamaConfig::default();
1180        assert_eq!(ollama_defaults.base_url, "http://localhost:11434");
1181        assert_eq!(ollama_defaults.model, "llama3.2");
1182    }
1183
1184    #[test]
1185    fn test_config_get() {
1186        let c = AppConfig::default();
1187        assert_eq!(c.get("llm.primary"), Some("anthropic".to_string()));
1188        assert_eq!(
1189            c.get("pipeline.agent_telemetry.enabled"),
1190            Some("false".to_string())
1191        );
1192        assert_eq!(
1193            c.get("pipeline.agent_telemetry.trace_export.enabled"),
1194            Some("false".to_string())
1195        );
1196        assert_eq!(
1197            c.get("pipeline.agent_telemetry.trace_export.protocol"),
1198            Some("grpc".to_string())
1199        );
1200        assert_eq!(c.get("privacy.crash_reports"), Some("true".to_string()));
1201        assert_eq!(c.get("unknown.key"), None);
1202    }
1203
1204    #[test]
1205    fn test_whisper_model_info() {
1206        let models = WhisperModelInfo::available_models();
1207        assert!(!models.is_empty());
1208        let recommended: Vec<_> = models.iter().filter(|m| m.recommended).collect();
1209        assert_eq!(recommended.len(), 1);
1210    }
1211
1212    #[test]
1213    fn test_backward_compatibility_without_pipeline_settings() {
1214        // Create a default config and serialize it
1215        let default_config = AppConfig::default();
1216        let mut json_value: serde_json::Value = serde_json::to_value(&default_config).unwrap();
1217
1218        // Remove the pipeline field to simulate an old config file
1219        json_value.as_object_mut().unwrap().remove("pipeline");
1220
1221        // Deserialize should succeed and use default pipeline settings
1222        let config: AppConfig = serde_json::from_value(json_value).unwrap();
1223
1224        // Verify pipeline settings have default values
1225        assert_eq!(config.pipeline.max_history_messages, 10);
1226        assert_eq!(config.pipeline.auto_compact_threshold_percent, 80);
1227        assert_eq!(
1228            config.pipeline.compaction_strategy,
1229            CompactionStrategy::Summarize
1230        );
1231        assert_eq!(config.pipeline.max_context_tokens, 0);
1232        assert!(config.pipeline.log_token_usage);
1233        assert!(!config.pipeline.agent_telemetry.enabled);
1234        assert!(!config.pipeline.agent_telemetry.trace_export.enabled);
1235        assert_eq!(
1236            config.pipeline.agent_telemetry.trace_export.protocol,
1237            AgentTelemetryTraceExportProtocol::Grpc
1238        );
1239    }
1240
1241    #[test]
1242    fn test_backward_compatibility_without_privacy_settings() {
1243        let default_config = AppConfig::default();
1244        let mut json_value: serde_json::Value = serde_json::to_value(&default_config).unwrap();
1245
1246        json_value.as_object_mut().unwrap().remove("privacy");
1247
1248        let config: AppConfig = serde_json::from_value(json_value).unwrap();
1249
1250        assert!(!config.privacy.data_collection);
1251        assert!(config.privacy.crash_reports);
1252        assert!(config.privacy.voice_data_local);
1253        assert!(!config.privacy.require_auth);
1254        assert_eq!(config.privacy.auth_timeout, 15);
1255    }
1256
1257    #[test]
1258    fn test_backward_compatibility_with_partial_pipeline_settings() {
1259        // Create a default config and serialize it
1260        let default_config = AppConfig::default();
1261        let mut json_value: serde_json::Value = serde_json::to_value(&default_config).unwrap();
1262
1263        // Modify pipeline to only have max_history_messages
1264        let pipeline_obj = serde_json::json!({
1265            "max_history_messages": 20
1266        });
1267        json_value
1268            .as_object_mut()
1269            .unwrap()
1270            .insert("pipeline".to_string(), pipeline_obj);
1271
1272        // Deserialize should succeed and use defaults for missing fields
1273        let config: AppConfig = serde_json::from_value(json_value).unwrap();
1274
1275        // Verify explicitly set value
1276        assert_eq!(config.pipeline.max_history_messages, 20);
1277
1278        // Verify other fields have default values
1279        assert_eq!(config.pipeline.auto_compact_threshold_percent, 80);
1280        assert_eq!(
1281            config.pipeline.compaction_strategy,
1282            CompactionStrategy::Summarize
1283        );
1284        assert_eq!(config.pipeline.max_context_tokens, 0);
1285        assert!(config.pipeline.log_token_usage);
1286        assert!(!config.pipeline.agent_telemetry.enabled);
1287        assert_eq!(
1288            config.pipeline.agent_telemetry.trace_export.protocol,
1289            AgentTelemetryTraceExportProtocol::Grpc
1290        );
1291        assert_eq!(
1292            config.pipeline.agent_telemetry.trace_export.endpoint,
1293            gestura_core_foundation::telemetry::DEFAULT_OTLP_TRACE_ENDPOINT
1294        );
1295    }
1296
1297    #[test]
1298    fn test_pipeline_settings_serialization_roundtrip() {
1299        // Create a config with custom pipeline settings
1300        let mut config = AppConfig::default();
1301        config.pipeline.max_history_messages = 15;
1302        config.pipeline.auto_compact_threshold_percent = 75;
1303        config.pipeline.compaction_strategy = CompactionStrategy::MemoryBank;
1304        config.pipeline.max_context_tokens = 50000;
1305        config.pipeline.log_token_usage = false;
1306        config.pipeline.agent_telemetry.enabled = true;
1307        config.pipeline.agent_telemetry.trace_export.enabled = true;
1308        config.pipeline.agent_telemetry.trace_export.protocol =
1309            AgentTelemetryTraceExportProtocol::Grpc;
1310        config.pipeline.agent_telemetry.trace_export.endpoint = "http://localhost:4317".to_string();
1311
1312        // Serialize to YAML
1313        let yaml = serde_yaml::to_string(&config).unwrap();
1314
1315        // Deserialize back
1316        let deserialized: AppConfig = serde_yaml::from_str(&yaml).unwrap();
1317
1318        // Verify all pipeline settings are preserved
1319        assert_eq!(deserialized.pipeline.max_history_messages, 15);
1320        assert_eq!(deserialized.pipeline.auto_compact_threshold_percent, 75);
1321        assert_eq!(
1322            deserialized.pipeline.compaction_strategy,
1323            CompactionStrategy::MemoryBank
1324        );
1325        assert_eq!(deserialized.pipeline.max_context_tokens, 50000);
1326        assert!(!deserialized.pipeline.log_token_usage);
1327        assert!(deserialized.pipeline.agent_telemetry.enabled);
1328        assert!(deserialized.pipeline.agent_telemetry.trace_export.enabled);
1329        assert_eq!(
1330            deserialized.pipeline.agent_telemetry.trace_export.protocol,
1331            AgentTelemetryTraceExportProtocol::Grpc
1332        );
1333        assert_eq!(
1334            deserialized.pipeline.agent_telemetry.trace_export.endpoint,
1335            "http://localhost:4317"
1336        );
1337    }
1338
1339    /// Global lock used to serialize environment-variable mutation across tests.
1340    fn env_lock() -> &'static Mutex<()> {
1341        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1342        LOCK.get_or_init(|| Mutex::new(()))
1343    }
1344
1345    /// Global lock used to serialize test-keychain mutation across tests.
1346    ///
1347    /// The keychain shim used in tests is process-global state, and Rust tests
1348    /// run in parallel by default.
1349    #[cfg(feature = "security")]
1350    fn keychain_lock() -> &'static Mutex<()> {
1351        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1352        LOCK.get_or_init(|| Mutex::new(()))
1353    }
1354
1355    /// RAII helper for setting a process-wide environment variable for the duration of a scope.
1356    ///
1357    /// ## Safety / Concurrency
1358    /// Environment variables are process-global state. Tests that use this helper should
1359    /// serialize access (e.g. by holding `env_lock()`) to avoid concurrent mutation.
1360    struct ScopedEnvVar {
1361        key: &'static str,
1362        old: Option<String>,
1363    }
1364
1365    impl ScopedEnvVar {
1366        fn set(key: &'static str, value: String) -> Self {
1367            let old = std::env::var(key).ok();
1368            // Rust 2024: mutating process-wide environment variables is `unsafe`.
1369            unsafe {
1370                std::env::set_var(key, value);
1371            }
1372            Self { key, old }
1373        }
1374
1375        #[allow(dead_code)]
1376        fn unset(key: &'static str) -> Self {
1377            let old = std::env::var(key).ok();
1378            // Rust 2024: mutating process-wide environment variables is `unsafe`.
1379            unsafe {
1380                std::env::remove_var(key);
1381            }
1382            Self { key, old }
1383        }
1384    }
1385
1386    impl Drop for ScopedEnvVar {
1387        fn drop(&mut self) {
1388            match &self.old {
1389                Some(v) => unsafe {
1390                    std::env::set_var(self.key, v);
1391                },
1392                None => unsafe {
1393                    std::env::remove_var(self.key);
1394                },
1395            }
1396        }
1397    }
1398
1399    #[test]
1400    #[cfg_attr(
1401        target_os = "windows",
1402        ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1403    )]
1404    fn migrates_legacy_json_config_to_yaml_on_load() {
1405        // This test mutates process-wide env vars; serialize it.
1406        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1407
1408        // When the `security` feature is enabled, `load()` will hydrate secrets from secure
1409        // storage. Ensure the test keychain shim is empty so we can compare against the legacy
1410        // JSON contents deterministically.
1411        #[cfg(feature = "security")]
1412        {
1413            let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1414            clear_test_keychain_store();
1415        }
1416
1417        let dir = tempfile::tempdir().unwrap();
1418        let home = dir.path().to_path_buf();
1419        let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1420        let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1421        let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1422        let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1423
1424        let gestura_dir = home.join(".gestura");
1425        fs::create_dir_all(&gestura_dir).unwrap();
1426
1427        let json_path = gestura_dir.join("config.json");
1428        let yaml_path = gestura_dir.join("config.yaml");
1429        let backup_path = gestura_dir.join("config.json.backup");
1430
1431        // Write legacy JSON config
1432        let cfg = AppConfig::default();
1433        let json = serde_json::to_string_pretty(&cfg).unwrap();
1434        fs::write(&json_path, json).unwrap();
1435        assert!(!yaml_path.exists());
1436
1437        // Loading should migrate and return the legacy config contents.
1438        let loaded = AppConfig::load();
1439        assert_eq!(loaded, cfg);
1440
1441        // YAML should exist after migration.
1442        assert!(yaml_path.exists());
1443
1444        // Legacy JSON should be backed up (best-effort).
1445        assert!(!json_path.exists() || backup_path.exists());
1446    }
1447
1448    #[test]
1449    #[cfg(feature = "security")]
1450    #[cfg_attr(
1451        target_os = "windows",
1452        ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1453    )]
1454    fn migrates_plaintext_openai_key_to_keystore_and_sanitizes_yaml_on_load() {
1455        // This test mutates process-wide env vars; serialize it.
1456        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1457
1458        // This test also mutates the process-global test keychain store.
1459        let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1460
1461        // This test uses the in-process keychain shim, so bare CI must not disable it.
1462        let _ci = ScopedEnvVar::unset("CI");
1463
1464        clear_test_keychain_store();
1465
1466        let dir = tempfile::tempdir().unwrap();
1467        let home = dir.path().to_path_buf();
1468        let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1469        let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1470        let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1471        let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1472
1473        let gestura_dir = home.join(".gestura");
1474        fs::create_dir_all(&gestura_dir).unwrap();
1475
1476        let yaml_path = gestura_dir.join("config.yaml");
1477
1478        let secret = "sk-test-openai-1234567890".to_string();
1479        let mut cfg = AppConfig::default();
1480        cfg.llm.openai = Some(OpenAiConfig {
1481            api_key: secret.clone(),
1482            ..Default::default()
1483        });
1484
1485        let yaml = serde_yaml::to_string(&cfg).unwrap();
1486        assert!(yaml.contains(&secret));
1487        fs::write(&yaml_path, yaml).unwrap();
1488
1489        // Load should migrate secrets to secure storage and then sanitize persisted YAML.
1490        let loaded = AppConfig::load();
1491        assert_eq!(
1492            loaded.llm.openai.as_ref().unwrap().api_key,
1493            secret,
1494            "loaded config should retain the secret in-memory"
1495        );
1496
1497        let persisted = fs::read_to_string(&yaml_path).unwrap();
1498        assert!(
1499            !persisted.contains(&secret),
1500            "persisted YAML must not contain plaintext secrets"
1501        );
1502
1503        let persisted_cfg: AppConfig = serde_yaml::from_str(&persisted).unwrap();
1504        assert!(
1505            persisted_cfg
1506                .llm
1507                .openai
1508                .as_ref()
1509                .is_some_and(|c| c.api_key.is_empty()),
1510            "persisted config should have openai.api_key cleared"
1511        );
1512
1513        // Second load should hydrate from secure storage (keychain shim in tests).
1514        let loaded_again = AppConfig::load();
1515        assert_eq!(
1516            loaded_again.llm.openai.as_ref().unwrap().api_key,
1517            secret,
1518            "second load should hydrate secret from secure storage"
1519        );
1520
1521        let persisted2 = fs::read_to_string(&yaml_path).unwrap();
1522        assert!(!persisted2.contains(&secret));
1523    }
1524
1525    #[test]
1526    #[cfg(feature = "security")]
1527    #[cfg_attr(
1528        target_os = "windows",
1529        ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1530    )]
1531    fn keychain_secret_overrides_yaml_and_plaintext_is_sanitized_on_load() {
1532        // This test mutates process-wide env vars; serialize it.
1533        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1534
1535        // This test also mutates the process-global test keychain store.
1536        let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1537
1538        // This test uses the in-process keychain shim, so bare CI must not disable it.
1539        let _ci = ScopedEnvVar::unset("CI");
1540
1541        clear_test_keychain_store();
1542
1543        // Seed secure storage with a canonical secret.
1544        let keychain_secret = "sk-keychain-openai-A";
1545        set_keychain_secret("gestura_llm_openai_api_key", keychain_secret).unwrap();
1546
1547        let dir = tempfile::tempdir().unwrap();
1548        let home = dir.path().to_path_buf();
1549        let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1550        let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1551        let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1552        let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1553
1554        let gestura_dir = home.join(".gestura");
1555        fs::create_dir_all(&gestura_dir).unwrap();
1556        let yaml_path = gestura_dir.join("config.yaml");
1557
1558        // Write a plaintext YAML secret that must be ignored (keychain-first).
1559        let yaml_secret = "sk-yaml-openai-B".to_string();
1560        let mut cfg = AppConfig::default();
1561        cfg.llm.openai = Some(OpenAiConfig {
1562            api_key: yaml_secret.clone(),
1563            ..Default::default()
1564        });
1565        let yaml = serde_yaml::to_string(&cfg).unwrap();
1566        assert!(yaml.contains(&yaml_secret));
1567        fs::write(&yaml_path, yaml).unwrap();
1568
1569        // Load should prefer keychain, and must sanitize the on-disk YAML.
1570        let loaded = AppConfig::load();
1571        assert_eq!(
1572            loaded.llm.openai.as_ref().unwrap().api_key,
1573            keychain_secret,
1574            "keychain secret must override plaintext YAML value"
1575        );
1576
1577        let persisted = fs::read_to_string(&yaml_path).unwrap();
1578        assert!(
1579            !persisted.contains(&yaml_secret),
1580            "persisted YAML must not contain plaintext secrets"
1581        );
1582        assert!(
1583            !persisted.contains(keychain_secret),
1584            "persisted YAML must not contain hydrated keychain secrets"
1585        );
1586
1587        // Secure storage should still contain the original keychain secret.
1588        let keychain_after = get_keychain_secret("gestura_llm_openai_api_key");
1589        assert_eq!(keychain_after.as_deref(), Some(keychain_secret));
1590    }
1591
1592    #[test]
1593    #[cfg(feature = "security")]
1594    #[cfg_attr(
1595        target_os = "windows",
1596        ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1597    )]
1598    fn fresh_install_with_keychain_openai_secret_bootstraps_provider_and_autoselects_primary() {
1599        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1600        let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1601
1602        // Ensure keychain access is not implicitly disabled in CI-like environments.
1603        let _ci = ScopedEnvVar::unset("CI");
1604
1605        clear_test_keychain_store();
1606        let secret = "sk-keychain-openai-fresh-install";
1607        set_keychain_secret("gestura_llm_openai_api_key", secret).unwrap();
1608
1609        // Fresh install: no YAML or legacy JSON.
1610        let dir = tempfile::tempdir().unwrap();
1611        let home = dir.path().to_path_buf();
1612        let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1613        let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1614        let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1615        let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1616
1617        let loaded = AppConfig::load();
1618
1619        assert_eq!(
1620            loaded.llm.openai.as_ref().unwrap().api_key,
1621            secret,
1622            "fresh install should hydrate OpenAI secret from keychain"
1623        );
1624        assert_eq!(
1625            loaded.llm.primary, "openai",
1626            "fresh install should autoselect a configured provider instead of staying on an unconfigured default"
1627        );
1628    }
1629
1630    #[test]
1631    #[cfg(feature = "security")]
1632    #[cfg_attr(
1633        target_os = "windows",
1634        ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1635    )]
1636    fn fresh_install_with_legacy_openai_secret_autoselects_and_self_heals() {
1637        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1638        let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1639        let _ci = ScopedEnvVar::unset("CI");
1640
1641        clear_test_keychain_store();
1642        let legacy_secret = "sk-legacy-openai-fresh-install";
1643        set_keychain_secret("gestura_api_key_openai", legacy_secret).unwrap();
1644
1645        let dir = tempfile::tempdir().unwrap();
1646        let home = dir.path().to_path_buf();
1647        let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1648        let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1649        let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1650        let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1651
1652        let loaded = AppConfig::load();
1653
1654        assert_eq!(loaded.llm.primary, "openai");
1655        assert_eq!(loaded.llm.openai.as_ref().unwrap().api_key, legacy_secret);
1656
1657        // Legacy fallback should self-heal to the canonical key name.
1658        let canonical = get_keychain_secret("gestura_llm_openai_api_key");
1659        assert_eq!(canonical.as_deref(), Some(legacy_secret));
1660    }
1661
1662    #[test]
1663    #[cfg(feature = "security")]
1664    #[cfg_attr(
1665        target_os = "windows",
1666        ignore = "dirs::home_dir() bypasses env var overrides on Windows"
1667    )]
1668    fn fresh_install_with_only_gemini_keychain_secret_autoselects_gemini() {
1669        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1670        let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1671        let _ci = ScopedEnvVar::unset("CI");
1672
1673        clear_test_keychain_store();
1674        let secret = "AIza-fresh-install-gemini";
1675        set_keychain_secret("gestura_llm_gemini_api_key", secret).unwrap();
1676
1677        let dir = tempfile::tempdir().unwrap();
1678        let home = dir.path().to_path_buf();
1679        let _home = ScopedEnvVar::set("HOME", home.to_string_lossy().to_string());
1680        let _userprofile = ScopedEnvVar::set("USERPROFILE", home.to_string_lossy().to_string());
1681        let _homedrive = ScopedEnvVar::set("HOMEDRIVE", "C:".to_string());
1682        let _homepath = ScopedEnvVar::set("HOMEPATH", "\\".to_string());
1683
1684        let loaded = AppConfig::load();
1685        assert_eq!(loaded.llm.primary, "gemini");
1686        assert_eq!(loaded.llm.gemini.as_ref().unwrap().api_key, secret);
1687    }
1688
1689    #[tokio::test]
1690    #[cfg(feature = "security")]
1691    async fn get_secret_with_legacy_prefers_canonical() {
1692        use crate::security::SecureStorage as _;
1693
1694        let storage = crate::security::MockSecureStorage::new();
1695        storage
1696            .store_secret("canonical_key", "canonical")
1697            .await
1698            .unwrap();
1699        storage.store_secret("legacy_key", "legacy").await.unwrap();
1700
1701        let v = get_secret_with_legacy(&storage, "canonical_key", "legacy_key").await;
1702        assert_eq!(v.as_deref(), Some("canonical"));
1703    }
1704
1705    #[tokio::test]
1706    #[cfg(feature = "security")]
1707    async fn get_secret_with_legacy_falls_back_and_self_heals() {
1708        use crate::security::SecureStorage as _;
1709
1710        let storage = crate::security::MockSecureStorage::new();
1711        storage.store_secret("legacy_key", "legacy").await.unwrap();
1712
1713        let v = get_secret_with_legacy(&storage, "canonical_key", "legacy_key").await;
1714        assert_eq!(v.as_deref(), Some("legacy"));
1715
1716        let canonical = storage.get_secret("canonical_key").await.unwrap();
1717        assert_eq!(canonical.as_deref(), Some("legacy"));
1718    }
1719
1720    #[test]
1721    #[cfg(feature = "security")]
1722    fn hydrate_secrets_sync_falls_back_to_legacy_keys_and_self_heals() {
1723        let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
1724        let _keychain_guard = keychain_lock().lock().unwrap_or_else(|e| e.into_inner());
1725
1726        // This test uses the in-process keychain shim, so bare CI must not disable it.
1727        let _ci = ScopedEnvVar::unset("CI");
1728
1729        clear_test_keychain_store();
1730
1731        // Pretend an older release stored the OpenAI key under a legacy key name.
1732        let legacy_secret = "sk-legacy-openai";
1733        set_keychain_secret("gestura_api_key_openai", legacy_secret).unwrap();
1734
1735        let mut cfg = AppConfig::default();
1736        cfg.llm.openai = Some(OpenAiConfig {
1737            api_key: String::new(),
1738            ..Default::default()
1739        });
1740
1741        cfg.hydrate_secrets_sync().unwrap();
1742        assert_eq!(cfg.llm.openai.as_ref().unwrap().api_key, legacy_secret);
1743
1744        // Self-heal should have copied the secret to the canonical key.
1745        let canonical = get_keychain_secret("gestura_llm_openai_api_key");
1746        assert_eq!(canonical.as_deref(), Some(legacy_secret));
1747    }
1748}