Skip to main content

reflex/semantic/
config.rs

1//! Configuration for semantic query feature
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::env;
7use std::path::{Path, PathBuf};
8
9/// Locate the user's home directory.
10///
11/// `dirs::home_dir()` queries `SHGetKnownFolderPath(FOLDERID_Profile)` on
12/// Windows and therefore ignores `HOME` / `USERPROFILE` env vars. That makes
13/// it impossible to redirect to a temp directory in tests. Honour those env
14/// vars (and `REFLEX_HOME` for an explicit override) before falling back to
15/// the OS-native lookup so test code can point us at a temp directory on
16/// every platform.
17fn user_home_dir() -> Option<PathBuf> {
18    for var in ["REFLEX_HOME", "HOME", "USERPROFILE"] {
19        if let Some(val) = env::var_os(var)
20            && !val.is_empty()
21        {
22            return Some(PathBuf::from(val));
23        }
24    }
25    dirs::home_dir()
26}
27
28/// MCP server configuration (read from `~/.reflex/config.toml`, `[mcp]` section)
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct McpConfig {
31    /// Include structural analysis tools in the MCP tool list.
32    /// Gated tools: find_circular, find_islands, find_unused, analyze_summary, get_transitive_deps.
33    /// Default: true (no change to existing behaviour).
34    #[serde(default = "default_structural_tools")]
35    pub enable_structural_tools: bool,
36}
37
38fn default_structural_tools() -> bool {
39    true
40}
41
42impl Default for McpConfig {
43    fn default() -> Self {
44        Self {
45            enable_structural_tools: true,
46        }
47    }
48}
49
50/// Load MCP config from `~/.reflex/config.toml` (`[mcp]` section).
51/// Falls back to defaults if the file or section is absent.
52pub fn load_mcp_config() -> McpConfig {
53    let home = match user_home_dir() {
54        Some(h) => h,
55        None => return McpConfig::default(),
56    };
57
58    let config_path = home.join(".reflex").join("config.toml");
59    if !config_path.exists() {
60        return McpConfig::default();
61    }
62
63    let config_str = match std::fs::read_to_string(&config_path) {
64        Ok(s) => s,
65        Err(_) => return McpConfig::default(),
66    };
67
68    let toml_value: toml::Value = match toml::from_str(&config_str) {
69        Ok(v) => v,
70        Err(_) => return McpConfig::default(),
71    };
72
73    // Warn about unknown keys within the [mcp] section
74    let known_mcp_keys = ["enable_structural_tools"];
75    if let Some(toml::Value::Table(mcp_table)) = toml_value.get("mcp") {
76        for key in mcp_table.keys() {
77            if !known_mcp_keys.contains(&key.as_str()) {
78                eprintln!(
79                    "[warn] ~/.reflex/config.toml: unknown key '[mcp].{}' — ignored",
80                    key
81                );
82            }
83        }
84    }
85
86    if let Some(mcp_table) = toml_value.get("mcp") {
87        mcp_table.clone().try_into().unwrap_or_default()
88    } else {
89        McpConfig::default()
90    }
91}
92
93/// Semantic query configuration
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SemanticConfig {
96    /// Enable semantic query feature
97    #[serde(default = "default_enabled")]
98    pub enabled: bool,
99
100    /// LLM provider (openai, anthropic, openrouter)
101    #[serde(default = "default_provider")]
102    pub provider: String,
103
104    /// Optional model override (uses provider default if None)
105    #[serde(default)]
106    pub model: Option<String>,
107
108    /// Auto-execute generated commands without confirmation
109    #[serde(default)]
110    pub auto_execute: bool,
111
112    /// Enable agentic mode (multi-step reasoning with context gathering)
113    #[serde(default = "default_agentic_enabled")]
114    pub agentic_enabled: bool,
115
116    /// Maximum iterations for query refinement in agentic mode
117    #[serde(default = "default_max_iterations")]
118    pub max_iterations: usize,
119
120    /// Maximum tool calls per context gathering phase
121    #[serde(default = "default_max_tools")]
122    pub max_tools_per_phase: usize,
123
124    /// Enable result evaluation in agentic mode
125    #[serde(default = "default_evaluation_enabled")]
126    pub evaluation_enabled: bool,
127
128    /// Evaluation strictness (0.0-1.0, higher is stricter)
129    #[serde(default = "default_strictness")]
130    pub evaluation_strictness: f32,
131
132    /// LLM request timeout in seconds (default: 30)
133    #[serde(default = "default_timeout_seconds")]
134    pub timeout_seconds: u64,
135}
136
137fn default_enabled() -> bool {
138    true
139}
140
141fn default_provider() -> String {
142    "openai".to_string()
143}
144
145fn default_agentic_enabled() -> bool {
146    false // Disabled by default, opt-in for experimental feature
147}
148
149fn default_max_iterations() -> usize {
150    2
151}
152
153fn default_max_tools() -> usize {
154    5
155}
156
157fn default_evaluation_enabled() -> bool {
158    true
159}
160
161fn default_strictness() -> f32 {
162    0.5
163}
164
165fn default_timeout_seconds() -> u64 {
166    30
167}
168
169impl Default for SemanticConfig {
170    fn default() -> Self {
171        Self {
172            enabled: true,
173            provider: "openai".to_string(),
174            model: None,
175            auto_execute: false,
176            agentic_enabled: false,
177            max_iterations: 2,
178            max_tools_per_phase: 5,
179            evaluation_enabled: true,
180            evaluation_strictness: 0.5,
181            timeout_seconds: 30,
182        }
183    }
184}
185
186/// Apply environment variable overrides to a semantic config.
187///
188/// Supports:
189/// - `REFLEX_PROVIDER` — overrides the provider (e.g., "openrouter", "anthropic", "openai")
190/// - `REFLEX_MODEL` — overrides the model
191///
192/// This enables CI/headless usage where there's no ~/.reflex/config.toml.
193fn apply_env_overrides(mut config: SemanticConfig) -> SemanticConfig {
194    if let Ok(provider) = env::var("REFLEX_PROVIDER")
195        && !provider.is_empty()
196    {
197        log::debug!(
198            "Overriding provider from REFLEX_PROVIDER env var: {}",
199            provider
200        );
201        config.provider = provider;
202    }
203
204    if let Ok(model) = env::var("REFLEX_MODEL")
205        && !model.is_empty()
206    {
207        log::debug!("Overriding model from REFLEX_MODEL env var: {}", model);
208        config.model = Some(model);
209    }
210
211    if let Ok(val) = env::var("REFLEX_LLM_TIMEOUT_SECONDS") {
212        match val.trim().parse::<u64>() {
213            Ok(secs) if secs > 0 => {
214                log::debug!(
215                    "Overriding LLM timeout from REFLEX_LLM_TIMEOUT_SECONDS: {}s",
216                    secs
217                );
218                config.timeout_seconds = secs;
219            }
220            _ => log::warn!(
221                "REFLEX_LLM_TIMEOUT_SECONDS is invalid (must be a positive integer): {}",
222                val
223            ),
224        }
225    }
226
227    config
228}
229
230/// Load semantic config from ~/.reflex/config.toml
231///
232/// Semantic configuration is ALWAYS user-level (not project-level).
233/// Falls back to defaults if file doesn't exist or [semantic] section is missing.
234/// Environment variables `REFLEX_PROVIDER` and `REFLEX_MODEL` override config file values.
235///
236/// Note: The cache_dir parameter is ignored - kept for API compatibility but will be removed in future.
237pub fn load_config(_cache_dir: &Path) -> Result<SemanticConfig> {
238    // Semantic config is always in user home directory, not project directory
239    let home = match user_home_dir() {
240        Some(h) => h,
241        None => {
242            log::debug!("Could not determine home directory, using defaults");
243            return Ok(apply_env_overrides(SemanticConfig::default()));
244        }
245    };
246
247    let config_path = home.join(".reflex").join("config.toml");
248
249    if !config_path.exists() {
250        log::debug!("No ~/.reflex/config.toml found, using default semantic config");
251        return Ok(apply_env_overrides(SemanticConfig::default()));
252    }
253
254    let config_str =
255        std::fs::read_to_string(&config_path).context("Failed to read ~/.reflex/config.toml")?;
256
257    let toml_value: toml::Value =
258        toml::from_str(&config_str).context("Failed to parse ~/.reflex/config.toml")?;
259
260    // REF-90: Warn about unknown top-level sections
261    let known_sections = [
262        "semantic",
263        "credentials",
264        "index",
265        "search",
266        "performance",
267        "mcp",
268    ];
269    if let Some(table) = toml_value.as_table() {
270        for key in table.keys() {
271            if !known_sections.contains(&key.as_str()) {
272                eprintln!(
273                    "[warn] ~/.reflex/config.toml: unknown section '[{}]' — ignored",
274                    key
275                );
276            }
277        }
278    }
279
280    // REF-90: Warn about unknown keys within the [semantic] section
281    let known_semantic_keys = ["provider", "model", "auto_execute"];
282    if let Some(toml::Value::Table(sem_table)) = toml_value.get("semantic") {
283        for key in sem_table.keys() {
284            if !known_semantic_keys.contains(&key.as_str()) {
285                eprintln!(
286                    "[warn] ~/.reflex/config.toml: unknown key '[semantic].{}' — ignored",
287                    key
288                );
289            }
290        }
291    }
292
293    // Extract [semantic] section
294    if let Some(semantic_table) = toml_value.get("semantic") {
295        let config: SemanticConfig = semantic_table
296            .clone()
297            .try_into()
298            .context("Failed to parse [semantic] section in ~/.reflex/config.toml")?;
299        log::debug!(
300            "Loaded semantic config from ~/.reflex/config.toml: provider={}",
301            config.provider
302        );
303        Ok(apply_env_overrides(config))
304    } else {
305        log::debug!("No [semantic] section in ~/.reflex/config.toml, using defaults");
306        Ok(apply_env_overrides(SemanticConfig::default()))
307    }
308}
309
310/// User configuration structure for ~/.reflex/config.toml
311#[derive(Debug, Clone, Serialize, Deserialize)]
312struct UserConfig {
313    #[serde(default)]
314    credentials: Option<Credentials>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318struct Credentials {
319    #[serde(default)]
320    openai_api_key: Option<String>,
321    #[serde(default)]
322    anthropic_api_key: Option<String>,
323    #[serde(default)]
324    openrouter_api_key: Option<String>,
325    #[serde(default)]
326    openai_compatible_api_key: Option<String>,
327    #[serde(default)]
328    openai_model: Option<String>,
329    #[serde(default)]
330    anthropic_model: Option<String>,
331    #[serde(default)]
332    openrouter_model: Option<String>,
333    #[serde(default)]
334    openai_compatible_model: Option<String>,
335    #[serde(default)]
336    openrouter_sort: Option<String>,
337    #[serde(default)]
338    openai_compatible_base_url: Option<String>,
339}
340
341/// Load user configuration from ~/.reflex/config.toml
342fn load_user_config() -> Result<Option<UserConfig>> {
343    let home = match user_home_dir() {
344        Some(h) => h,
345        None => {
346            log::debug!("Could not determine home directory");
347            return Ok(None);
348        }
349    };
350
351    let config_path = home.join(".reflex").join("config.toml");
352
353    if !config_path.exists() {
354        log::debug!("No user config found at ~/.reflex/config.toml");
355        return Ok(None);
356    }
357
358    let config_str =
359        std::fs::read_to_string(&config_path).context("Failed to read ~/.reflex/config.toml")?;
360
361    let config: UserConfig =
362        toml::from_str(&config_str).context("Failed to parse ~/.reflex/config.toml")?;
363
364    Ok(Some(config))
365}
366
367/// Get API key for a provider
368///
369/// Checks in priority order:
370/// 1. ~/.reflex/config.toml (user config file)
371/// 2. REFLEX_AI_API_KEY environment variable (generic, provider-agnostic)
372/// 3. {PROVIDER}_API_KEY environment variable (e.g., OPENAI_API_KEY)
373/// 4. Error if not found
374pub fn get_api_key(provider: &str) -> Result<String> {
375    let provider_lc = provider.to_lowercase();
376    let is_openai_compatible =
377        provider_lc == "openai-compatible" || provider_lc == "openai_compatible";
378
379    // First check user config file
380    if let Ok(Some(user_config)) = load_user_config()
381        && let Some(credentials) = &user_config.credentials
382    {
383        // Get the appropriate key based on provider
384        let key = match provider_lc.as_str() {
385            "openai" => credentials.openai_api_key.as_ref(),
386            "anthropic" => credentials.anthropic_api_key.as_ref(),
387            "openrouter" => credentials.openrouter_api_key.as_ref(),
388            "openai-compatible" | "openai_compatible" => {
389                credentials.openai_compatible_api_key.as_ref()
390            }
391            _ => None,
392        };
393
394        if let Some(api_key) = key {
395            log::debug!("Using {} API key from ~/.reflex/config.toml", provider);
396            return Ok(api_key.clone());
397        }
398    }
399
400    // Check generic REFLEX_AI_API_KEY env var (provider-agnostic, useful for CI)
401    if let Ok(key) = env::var("REFLEX_AI_API_KEY")
402        && !key.is_empty()
403    {
404        log::debug!(
405            "Using API key from REFLEX_AI_API_KEY env var for provider '{}'",
406            provider
407        );
408        return Ok(key);
409    }
410
411    // Fall back to provider-specific environment variables
412    let env_var = match provider_lc.as_str() {
413        "openai" => "OPENAI_API_KEY",
414        "anthropic" => "ANTHROPIC_API_KEY",
415        "openrouter" => "OPENROUTER_API_KEY",
416        "openai-compatible" | "openai_compatible" => "OPENAI_COMPATIBLE_API_KEY",
417        _ => anyhow::bail!("Unknown provider: {}", provider),
418    };
419
420    if let Ok(key) = env::var(env_var) {
421        return Ok(key);
422    }
423
424    // openai-compatible can run keyless against local servers — return empty
425    // string instead of erroring. Caller is responsible for ensuring base_url
426    // is configured separately.
427    if is_openai_compatible {
428        log::debug!(
429            "No API key configured for openai-compatible; sending requests without auth header"
430        );
431        return Ok(String::new());
432    }
433
434    Err(anyhow::anyhow!(
435        "API key not found for provider '{}'.\n\
436         \n\
437         Either:\n\
438         1. Run 'rfx llm config' to set up your API key interactively\n\
439         2. Set REFLEX_AI_API_KEY (works with any provider)\n\
440         3. Set the {} environment variable\n\
441         \n\
442         Example: export REFLEX_AI_API_KEY=sk-...",
443        provider,
444        env_var
445    ))
446}
447
448/// Check if any API key is configured for any supported provider
449///
450/// Checks in priority order:
451/// 1. ~/.reflex/config.toml (credentials section)
452/// 2. REFLEX_AI_API_KEY environment variable (generic)
453/// 3. Provider-specific environment variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY)
454///
455/// Returns true if at least one API key is found for any provider.
456pub fn is_any_api_key_configured() -> bool {
457    // Check user config file first
458    if let Ok(Some(user_config)) = load_user_config()
459        && let Some(credentials) = &user_config.credentials
460    {
461        // Check if any provider has an API key in the config file
462        if credentials.openai_api_key.is_some()
463                || credentials.anthropic_api_key.is_some()
464                || credentials.openrouter_api_key.is_some()
465                || credentials.openai_compatible_api_key.is_some()
466                // openai-compatible can run keyless — a configured base_url
467                // counts as "configured" even without an API key.
468                || credentials.openai_compatible_base_url.is_some()
469        {
470            log::debug!("Found provider credential in ~/.reflex/config.toml");
471            return true;
472        }
473    }
474
475    // Check generic REFLEX_AI_API_KEY
476    if let Ok(key) = env::var("REFLEX_AI_API_KEY")
477        && !key.is_empty()
478    {
479        log::debug!("Found REFLEX_AI_API_KEY env var");
480        return true;
481    }
482
483    // Check provider-specific environment variables
484    let env_vars = [
485        "OPENAI_API_KEY",
486        "ANTHROPIC_API_KEY",
487        "OPENROUTER_API_KEY",
488        "OPENAI_COMPATIBLE_API_KEY",
489        "OPENAI_COMPATIBLE_BASE_URL",
490    ];
491
492    for env_var in &env_vars {
493        if env::var(env_var).is_ok() {
494            log::debug!("Found {} environment variable", env_var);
495            return true;
496        }
497    }
498
499    log::debug!("No provider credentials found in config or environment variables");
500    false
501}
502
503/// Get the preferred model for a provider from user config
504///
505/// Returns None if no model is configured for this provider.
506/// The caller should use provider defaults if None is returned.
507pub fn get_user_model(provider: &str) -> Option<String> {
508    if let Ok(Some(user_config)) = load_user_config()
509        && let Some(credentials) = &user_config.credentials
510    {
511        let model = match provider.to_lowercase().as_str() {
512            "openai" => credentials.openai_model.as_ref(),
513            "anthropic" => credentials.anthropic_model.as_ref(),
514            "openrouter" => credentials.openrouter_model.as_ref(),
515            "openai-compatible" | "openai_compatible" => {
516                credentials.openai_compatible_model.as_ref()
517            }
518            _ => None,
519        };
520
521        if let Some(model_name) = model {
522            log::debug!(
523                "Using {} model from ~/.reflex/config.toml: {}",
524                provider,
525                model_name
526            );
527            return Some(model_name.clone());
528        }
529    }
530
531    // Fall back to OPENAI_COMPATIBLE_MODEL env var for the openai-compatible provider
532    let provider_lc = provider.to_lowercase();
533    if (provider_lc == "openai-compatible" || provider_lc == "openai_compatible")
534        && let Ok(model) = env::var("OPENAI_COMPATIBLE_MODEL")
535        && !model.is_empty()
536    {
537        log::debug!(
538            "Using openai-compatible model from OPENAI_COMPATIBLE_MODEL env var: {}",
539            model
540        );
541        return Some(model);
542    }
543
544    None
545}
546
547/// Resolve the effective model for an LLM call.
548///
549/// Precedence:
550///   1. Explicit override (CLI flag, `--model`, `/model` command arg, etc.)
551///   2. `[semantic] model` from `~/.reflex/config.toml` (also receives
552///      `REFLEX_MODEL` env var via `apply_env_overrides`)
553///   3. `[credentials] {provider}_model` via `get_user_model`
554///   4. `None` — caller's provider constructor applies its own default
555///
556/// Returning `None` lets each provider keep its own built-in default
557/// (e.g. OpenAI → `gpt-4o-mini`). The openai-compatible provider has no
558/// default and will error if `None` is returned, which is the correct
559/// behavior for self-hosted endpoints — the fix is to configure a model.
560pub fn resolve_model(config: &SemanticConfig, override_model: Option<&str>) -> Option<String> {
561    resolve_model_for(&config.provider, config.model.as_deref(), override_model)
562}
563
564/// Same as [`resolve_model`] but takes provider/project-model separately.
565///
566/// Use when the caller has resolved a provider that may not match
567/// `semantic_config.provider` — e.g. `pulse/narrate.rs` auto-detects a
568/// provider with a working API key when the configured one has none.
569pub fn resolve_model_for(
570    provider: &str,
571    project_model: Option<&str>,
572    override_model: Option<&str>,
573) -> Option<String> {
574    override_model
575        .map(String::from)
576        .or_else(|| project_model.map(String::from))
577        .or_else(|| get_user_model(provider))
578}
579
580/// Save user's provider/model preference to ~/.reflex/config.toml
581///
582/// Updates the [credentials] section with the new model for the specified provider.
583/// Creates the config file and directory if they don't exist.
584pub fn save_user_provider(provider: &str, model: Option<&str>) -> Result<()> {
585    let home = user_home_dir().context("Cannot find home directory")?;
586    let config_dir = home.join(".reflex");
587    let config_path = config_dir.join("config.toml");
588
589    // Create directory if needed
590    std::fs::create_dir_all(&config_dir).context("Failed to create ~/.reflex directory")?;
591
592    // Read existing config or create empty
593    let mut config: toml::Value = if config_path.exists() {
594        let content = std::fs::read_to_string(&config_path)
595            .context("Failed to read ~/.reflex/config.toml")?;
596        toml::from_str(&content).context("Failed to parse ~/.reflex/config.toml")?
597    } else {
598        toml::Value::Table(toml::map::Map::new())
599    };
600
601    // Ensure [credentials] section exists
602    let credentials = config
603        .as_table_mut()
604        .context("Config root is not a table")?
605        .entry("credentials")
606        .or_insert(toml::Value::Table(toml::map::Map::new()))
607        .as_table_mut()
608        .context("[credentials] is not a table")?;
609
610    // Set model for this provider (if provided)
611    if let Some(m) = model {
612        let key = format!("{}_model", provider.to_lowercase());
613        credentials.insert(key, toml::Value::String(m.to_string()));
614        log::info!("Saved {} model: {}", provider, m);
615    }
616
617    // Write back to file
618    let toml_str = toml::to_string_pretty(&config).context("Failed to serialize config to TOML")?;
619    std::fs::write(&config_path, toml_str).context("Failed to write ~/.reflex/config.toml")?;
620
621    Ok(())
622}
623
624/// Get provider-specific options from user config
625///
626/// Returns `Some(HashMap)` for providers that need extra settings (e.g., OpenRouter sort strategy).
627/// Returns `None` for providers with no additional options.
628pub fn get_provider_options(provider: &str) -> Option<HashMap<String, String>> {
629    let provider_lc = provider.to_lowercase();
630
631    match provider_lc.as_str() {
632        "openrouter" => {
633            if let Ok(Some(user_config)) = load_user_config()
634                && let Some(credentials) = &user_config.credentials
635                && let Some(sort) = &credentials.openrouter_sort
636            {
637                let mut opts = HashMap::new();
638                opts.insert("sort".to_string(), sort.clone());
639                return Some(opts);
640            }
641            None
642        }
643        "openai-compatible" | "openai_compatible" => {
644            // base_url priority: config file → OPENAI_COMPATIBLE_BASE_URL env var
645            let base_url = load_user_config()
646                .ok()
647                .flatten()
648                .and_then(|cfg| cfg.credentials)
649                .and_then(|c| c.openai_compatible_base_url)
650                .or_else(|| env::var("OPENAI_COMPATIBLE_BASE_URL").ok())
651                .filter(|s| !s.is_empty());
652
653            base_url.map(|url| {
654                let mut opts = HashMap::new();
655                opts.insert("base_url".to_string(), url);
656                opts
657            })
658        }
659        _ => None,
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use std::sync::{Mutex, MutexGuard};
667    use tempfile::TempDir;
668
669    /// Tests in this module manipulate process-wide environment variables
670    /// (`HOME`, `OPENAI_API_KEY`, etc.). Cargo runs tests in parallel by
671    /// default, which causes races: one test's `env::remove_var("HOME")`
672    /// executes mid-flight while another test is reading config from a
673    /// `HOME`-rooted path. Acquire this mutex at the start of every test
674    /// that touches env state to serialize them. Tests that don't touch
675    /// env state can omit it.
676    static ENV_LOCK: Mutex<()> = Mutex::new(());
677
678    /// Acquire the env-state lock for the duration of a test. Drops on
679    /// scope exit, restoring parallelism. Robust to poisoning from a
680    /// panicking test (recover instead of propagating).
681    fn env_guard() -> MutexGuard<'static, ()> {
682        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
683    }
684
685    /// Point `dirs::home_dir()` at the given path. On Unix that means
686    /// `HOME`; on Windows the resolver reads `USERPROFILE` instead, so we
687    /// must set the platform-appropriate variable for the override to take
688    /// effect.
689    fn set_home(path: &std::path::Path) {
690        unsafe {
691            env::set_var("HOME", path);
692            if cfg!(windows) {
693                env::set_var("USERPROFILE", path);
694            }
695        }
696    }
697
698    /// Reset the home override applied by [`set_home`].
699    fn unset_home() {
700        unsafe {
701            env::remove_var("HOME");
702            if cfg!(windows) {
703                env::remove_var("USERPROFILE");
704            }
705        }
706    }
707
708    #[test]
709    fn test_default_config() {
710        let config = SemanticConfig::default();
711        assert!(config.enabled);
712        assert_eq!(config.provider, "openai");
713        assert_eq!(config.model, None);
714        assert!(!config.auto_execute);
715    }
716
717    #[test]
718    fn test_load_config_no_file() {
719        let _g = env_guard();
720        let temp = TempDir::new().unwrap();
721
722        // Set HOME to temp directory to avoid loading user's config
723        unsafe {
724            env::set_var("HOME", temp.path());
725        }
726        let config = load_config(temp.path()).unwrap();
727        unsafe {
728            env::remove_var("HOME");
729        }
730
731        // Should return defaults
732        assert_eq!(config.provider, "openai");
733        assert!(config.enabled);
734    }
735
736    #[test]
737    fn test_load_config_with_semantic_section() {
738        let _g = env_guard();
739        let temp = TempDir::new().unwrap();
740        let reflex_dir = temp.path().join(".reflex");
741        std::fs::create_dir_all(&reflex_dir).unwrap();
742        let config_path = reflex_dir.join("config.toml");
743
744        std::fs::write(
745            &config_path,
746            r#"
747[semantic]
748enabled = true
749provider = "anthropic"
750model = "claude-3-5-sonnet-20241022"
751auto_execute = true
752            "#,
753        )
754        .unwrap();
755
756        // Set HOME to temp directory to load test config
757        set_home(temp.path());
758        let config = load_config(temp.path()).unwrap();
759        unset_home();
760
761        assert!(config.enabled);
762        assert_eq!(config.provider, "anthropic");
763        assert_eq!(config.model, Some("claude-3-5-sonnet-20241022".to_string()));
764        assert!(config.auto_execute);
765    }
766
767    #[test]
768    fn test_load_config_without_semantic_section() {
769        let _g = env_guard();
770        let temp = TempDir::new().unwrap();
771        let reflex_dir = temp.path().join(".reflex");
772        std::fs::create_dir_all(&reflex_dir).unwrap();
773        let config_path = reflex_dir.join("config.toml");
774
775        std::fs::write(
776            &config_path,
777            r#"
778[index]
779languages = []
780            "#,
781        )
782        .unwrap();
783
784        // Set HOME to temp directory to load test config
785        unsafe {
786            env::set_var("HOME", temp.path());
787        }
788        let config = load_config(temp.path()).unwrap();
789        unsafe {
790            env::remove_var("HOME");
791        }
792
793        // Should return defaults
794        assert_eq!(config.provider, "openai");
795    }
796
797    #[test]
798    fn test_get_api_key_env_var() {
799        let _g = env_guard();
800        let temp = TempDir::new().unwrap();
801
802        // Set HOME to temp directory to avoid loading user's config
803        unsafe {
804            env::set_var("HOME", temp.path());
805            env::set_var("OPENAI_API_KEY", "test-key-123");
806        }
807
808        let key = get_api_key("openai").unwrap();
809        assert_eq!(key, "test-key-123");
810
811        unsafe {
812            env::remove_var("OPENAI_API_KEY");
813            env::remove_var("HOME");
814        }
815    }
816
817    #[test]
818    fn test_get_api_key_missing() {
819        let _g = env_guard();
820        let temp = TempDir::new().unwrap();
821
822        // Set HOME to temp directory to avoid loading user's config
823        unsafe {
824            env::set_var("HOME", temp.path());
825            env::remove_var("OPENROUTER_API_KEY");
826            env::remove_var("REFLEX_AI_API_KEY");
827        }
828
829        let result = get_api_key("openrouter");
830        assert!(result.is_err());
831        assert!(
832            result
833                .unwrap_err()
834                .to_string()
835                .contains("OPENROUTER_API_KEY")
836        );
837
838        unsafe {
839            env::remove_var("HOME");
840        }
841    }
842
843    #[test]
844    fn test_get_api_key_unknown_provider() {
845        let _g = env_guard();
846        let result = get_api_key("unknown");
847        assert!(result.is_err());
848        assert!(result.unwrap_err().to_string().contains("Unknown provider"));
849    }
850
851    #[test]
852    fn test_env_override_provider() {
853        let _g = env_guard();
854        let temp = TempDir::new().unwrap();
855
856        unsafe {
857            env::set_var("HOME", temp.path());
858            env::set_var("REFLEX_PROVIDER", "openrouter");
859        }
860
861        let config = load_config(temp.path()).unwrap();
862
863        unsafe {
864            env::remove_var("REFLEX_PROVIDER");
865            env::remove_var("HOME");
866        }
867
868        assert_eq!(config.provider, "openrouter");
869    }
870
871    #[test]
872    fn test_env_override_model() {
873        let _g = env_guard();
874        let temp = TempDir::new().unwrap();
875
876        unsafe {
877            env::set_var("HOME", temp.path());
878            env::set_var("REFLEX_MODEL", "google/gemini-2.5-flash");
879        }
880
881        let config = load_config(temp.path()).unwrap();
882
883        unsafe {
884            env::remove_var("REFLEX_MODEL");
885            env::remove_var("HOME");
886        }
887
888        assert_eq!(config.model, Some("google/gemini-2.5-flash".to_string()));
889        // Provider should remain the default since we didn't override it
890        assert_eq!(config.provider, "openai");
891    }
892
893    #[test]
894    fn test_get_api_key_generic_env_var() {
895        let _g = env_guard();
896        let temp = TempDir::new().unwrap();
897
898        unsafe {
899            env::set_var("HOME", temp.path());
900            env::remove_var("OPENROUTER_API_KEY");
901            env::set_var("REFLEX_AI_API_KEY", "generic-key-456");
902        }
903
904        let key = get_api_key("openrouter").unwrap();
905        assert_eq!(key, "generic-key-456");
906
907        unsafe {
908            env::remove_var("REFLEX_AI_API_KEY");
909            env::remove_var("HOME");
910        }
911    }
912
913    #[test]
914    fn test_get_api_key_openai_compatible_returns_empty_when_unset() {
915        let _g = env_guard();
916        let temp = TempDir::new().unwrap();
917
918        unsafe {
919            env::set_var("HOME", temp.path());
920            env::remove_var("OPENAI_COMPATIBLE_API_KEY");
921            env::remove_var("REFLEX_AI_API_KEY");
922        }
923
924        // For openai-compatible, missing key is OK (local servers don't require auth)
925        let key = get_api_key("openai-compatible").unwrap();
926        assert_eq!(key, "");
927
928        unsafe {
929            env::remove_var("HOME");
930        }
931    }
932
933    #[test]
934    fn test_get_provider_options_openai_compatible_from_config() {
935        let _g = env_guard();
936        let temp = TempDir::new().unwrap();
937        let reflex_dir = temp.path().join(".reflex");
938        std::fs::create_dir_all(&reflex_dir).unwrap();
939        let config_path = reflex_dir.join("config.toml");
940
941        std::fs::write(
942            &config_path,
943            r#"
944[credentials]
945openai_compatible_base_url = "http://localhost:1234/v1"
946openai_compatible_model = "qwen2.5-coder"
947            "#,
948        )
949        .unwrap();
950
951        unsafe {
952            env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
953        }
954        set_home(temp.path());
955
956        let opts = get_provider_options("openai-compatible");
957        let model = get_user_model("openai-compatible");
958
959        unset_home();
960
961        let opts = opts.expect("base_url should be discovered from config");
962        assert_eq!(
963            opts.get("base_url").map(|s| s.as_str()),
964            Some("http://localhost:1234/v1")
965        );
966        assert_eq!(model, Some("qwen2.5-coder".to_string()));
967    }
968
969    #[test]
970    fn test_get_provider_options_openai_compatible_from_env() {
971        let _g = env_guard();
972        let temp = TempDir::new().unwrap();
973
974        unsafe {
975            env::set_var("HOME", temp.path());
976            env::set_var("OPENAI_COMPATIBLE_BASE_URL", "http://localhost:11434/v1");
977        }
978
979        let opts = get_provider_options("openai-compatible");
980
981        unsafe {
982            env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
983            env::remove_var("HOME");
984        }
985
986        let opts = opts.expect("base_url should be discovered from env var");
987        assert_eq!(
988            opts.get("base_url").map(|s| s.as_str()),
989            Some("http://localhost:11434/v1")
990        );
991    }
992
993    fn config_with(provider: &str, project_model: Option<&str>) -> SemanticConfig {
994        SemanticConfig {
995            provider: provider.to_string(),
996            model: project_model.map(String::from),
997            ..SemanticConfig::default()
998        }
999    }
1000
1001    #[test]
1002    fn resolve_model_prefers_override() {
1003        let config = config_with("openai", Some("gpt-4o"));
1004        let resolved = resolve_model(&config, Some("gpt-4o-2024-08-06"));
1005        assert_eq!(resolved.as_deref(), Some("gpt-4o-2024-08-06"));
1006    }
1007
1008    #[test]
1009    fn resolve_model_falls_back_to_project_config() {
1010        let config = config_with("openai", Some("gpt-4o"));
1011        let resolved = resolve_model(&config, None);
1012        assert_eq!(resolved.as_deref(), Some("gpt-4o"));
1013    }
1014
1015    #[test]
1016    fn resolve_model_returns_none_when_unset() {
1017        let _g = env_guard();
1018        // No override, no [semantic] model, no [credentials] entry — caller
1019        // is expected to fall back to the provider's own default.
1020        let temp = TempDir::new().unwrap();
1021        unsafe {
1022            env::set_var("HOME", temp.path());
1023        }
1024
1025        let config = config_with("openai", None);
1026        let resolved = resolve_model(&config, None);
1027
1028        unsafe {
1029            env::remove_var("HOME");
1030        }
1031
1032        assert_eq!(resolved, None);
1033    }
1034
1035    #[test]
1036    fn resolve_model_for_openai_compatible_reads_user_config() {
1037        let _g = env_guard();
1038        // The actual bug repro at the unit level: model lives in
1039        // ~/.reflex/config.toml [credentials] openai_compatible_model and
1040        // resolve_model_for must surface it when override + project are None.
1041        let temp = TempDir::new().unwrap();
1042        let reflex_dir = temp.path().join(".reflex");
1043        std::fs::create_dir_all(&reflex_dir).unwrap();
1044        std::fs::write(
1045            reflex_dir.join("config.toml"),
1046            r#"
1047[credentials]
1048openai_compatible_model = "gpt-oss:20b-cloud"
1049            "#,
1050        )
1051        .unwrap();
1052
1053        set_home(temp.path());
1054
1055        let resolved = resolve_model_for("openai-compatible", None, None);
1056
1057        unset_home();
1058
1059        assert_eq!(resolved.as_deref(), Some("gpt-oss:20b-cloud"));
1060    }
1061
1062    #[test]
1063    fn resolve_model_for_override_beats_user_config() {
1064        let _g = env_guard();
1065        let temp = TempDir::new().unwrap();
1066        let reflex_dir = temp.path().join(".reflex");
1067        std::fs::create_dir_all(&reflex_dir).unwrap();
1068        std::fs::write(
1069            reflex_dir.join("config.toml"),
1070            r#"
1071[credentials]
1072openrouter_model = "anthropic/claude-opus-4"
1073            "#,
1074        )
1075        .unwrap();
1076
1077        unsafe {
1078            env::set_var("HOME", temp.path());
1079        }
1080
1081        let resolved = resolve_model_for("openrouter", None, Some("openai/gpt-4o"));
1082
1083        unsafe {
1084            env::remove_var("HOME");
1085        }
1086
1087        assert_eq!(resolved.as_deref(), Some("openai/gpt-4o"));
1088    }
1089}