Skip to main content

oxi_store/
settings.rs

1//! Settings management for oxi CLI
2//!
3//! Settings are loaded in layers (later layers override earlier):
4//! 1. Built-in defaults
5//! 2. Global config: `~/.oxi/settings.toml`
6//! 3. Project config: `.oxi/settings.toml` (walked up to repo root)
7//! 4. Environment variables (`OXI_*` prefix)
8//! 5. CLI arguments
9//!
10//! Migration is handled via a `version` field in the config file.
11
12use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::env;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19/// Current settings format version.
20const SETTINGS_VERSION: u32 = 4;
21
22/// Environment variable prefix for oxi settings.
23/// Keep: reserved for future env-based config loading (e.g. OXI_API_KEY).
24#[allow(dead_code)]
25const ENV_PREFIX: &str = "OXI_";
26
27/// Thinking level for agent responses
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum ThinkingLevel {
31    /// Extended reasoning disabled (default).
32    #[default]
33    Off,
34    /// Minimal reasoning.
35    Minimal,
36    /// Low reasoning.
37    Low,
38    /// Medium reasoning.
39    Medium,
40    /// High reasoning.
41    High,
42    /// Very high reasoning.
43    XHigh,
44}
45
46/// A custom OpenAI-compatible provider configuration.
47///
48/// Custom providers are loaded from `~/.oxi/settings.toml` via `[[custom_provider]]` sections
49/// and registered at runtime so that models like `minimax/minimax-m2.5` can be used directly.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CustomProvider {
52    /// Unique provider name (e.g. `"minimax"`).
53    pub name: String,
54    /// Base URL of the OpenAI-compatible API (e.g. `"https://api.minimax.chat/v1"`).
55    pub base_url: String,
56    /// Environment variable name that holds the API key (e.g. `"MINIMAX_API_KEY"`).
57    pub api_key_env: String,
58    /// API dialect: `"openai-completions"` or `"openai-responses"`.
59    #[serde(default = "default_custom_provider_api")]
60    pub api: String,
61}
62
63fn default_custom_provider_api() -> String {
64    "openai-completions".to_string()
65}
66
67/// Application settings
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Settings {
70    // ── Version (for migration) ──────────────────────────────────────
71    /// Settings format version. Used for automatic migration.
72    #[serde(default)]
73    pub version: u32,
74
75    // ── Core LLM settings ───────────────────────────────────────────
76    /// Thinking level for agent responses
77    #[serde(default = "default_thinking_level")]
78    pub thinking_level: ThinkingLevel,
79
80    /// Color theme (e.g., "default", "monokai", "dracula")
81    #[serde(default = "default_theme")]
82    pub theme: String,
83
84    /// Deprecated: use `last_used_model` instead. Kept for serde backward compat.
85    #[serde(default, skip_serializing)]
86    pub default_model: Option<String>,
87
88    /// Deprecated: use `last_used_provider` instead. Kept for serde backward compat.
89    #[serde(default, skip_serializing)]
90    pub default_provider: Option<String>,
91
92    /// Model selected by the user (last used = current default).
93    /// Set during onboarding and updated every time the user switches model.
94    #[serde(default)]
95    pub last_used_model: Option<String>,
96
97    /// Provider for the last used model.
98    #[serde(default)]
99    pub last_used_provider: Option<String>,
100
101    /// Max tokens for responses
102    pub max_tokens: Option<u32>,
103
104    /// Temperature for generation (0.0–2.0)
105    pub temperature: Option<f32>,
106
107    /// Default temperature as f64 (higher precision, takes precedence over `temperature`)
108    pub default_temperature: Option<f64>,
109
110    /// Maximum tokens for generation (usize variant, takes precedence over `max_tokens`)
111    pub max_response_tokens: Option<usize>,
112
113    // ── Session settings ─────────────────────────────────────────────
114    /// Session history size (entries to keep in memory)
115    #[serde(default = "default_session_history_size")]
116    pub session_history_size: usize,
117
118    /// Directory for storing sessions (default: `~/.oxi/sessions`)
119    pub session_dir: Option<PathBuf>,
120
121    // ── Behaviour flags ──────────────────────────────────────────────
122    /// Whether to stream responses
123    #[serde(default = "default_true")]
124    pub stream_responses: bool,
125
126    /// Whether extensions are enabled
127    #[serde(default = "default_true")]
128    pub extensions_enabled: bool,
129
130    /// Whether to auto-compact conversations that exceed context window
131    #[serde(default = "default_true")]
132    pub auto_compaction: bool,
133
134    /// Built-in tools to disable (by name, e.g. `["web_search", "github_search"]`).
135    /// All tools are enabled by default; list tools here to turn them off.
136    #[serde(default)]
137    pub disabled_tools: Vec<String>,
138
139    // ── Timeouts ─────────────────────────────────────────────────────
140    /// Timeout in seconds for tool execution
141    #[serde(default = "default_tool_timeout")]
142    pub tool_timeout_seconds: u64,
143
144    // ── Resource lists (managed by `oxi config`) ────────────────────
145    /// List of extension paths or npm package sources to load
146    #[serde(default)]
147    pub extensions: Vec<String>,
148
149    /// List of skill paths or npm package sources to load
150    #[serde(default)]
151    pub skills: Vec<String>,
152
153    /// List of prompt template paths to load
154    #[serde(default)]
155    pub prompts: Vec<String>,
156
157    /// List of theme paths to load
158    #[serde(default)]
159    pub themes: Vec<String>,
160
161    // ── Custom OpenAI-compatible providers ──────────────────────────────
162    /// Registered custom providers (loaded from `[[custom_provider]]` TOML sections).
163    #[serde(default)]
164    pub custom_providers: Vec<CustomProvider>,
165
166    // ── Dynamic model cache ─────────────────────────────────────────────
167    /// Cached model lists fetched from provider `/models` endpoints.
168    /// Key is the provider name, value is a list of model IDs.
169    /// Updated when API keys are entered in setup wizard or on demand.
170    #[serde(default)]
171    pub dynamic_models: HashMap<String, Vec<String>>,
172
173    // ── Multi-provider routing ─────────────────────────────────────────
174    /// Enable automatic complexity-based routing
175    #[serde(default = "default_false")]
176    pub enable_routing: bool,
177
178    /// Router profile name to use (e.g., "auto", "balanced").
179    #[serde(default)]
180    pub router_profile: Option<String>,
181
182    /// Prefer cost-efficient models when routing
183    #[serde(default = "default_true")]
184    pub prefer_cost_efficient: bool,
185
186    /// Fallback chain: ordered list of model IDs to try on failure
187    #[serde(default)]
188    pub fallback_chain: Vec<String>,
189
190    /// Whether to use provider fallback on errors (false = fail fast)
191    #[serde(default = "default_true")]
192    pub enable_fallback: bool,
193
194    /// Disable automatic fallback (same as enable_fallback = false)
195    #[serde(default)]
196    pub disable_fallback: bool,
197
198    /// Circuit breaker failure threshold per provider
199    #[serde(default = "default_circuit_failure_threshold")]
200    pub circuit_breaker_failure_threshold: u32,
201
202    /// Circuit breaker open duration in seconds
203    #[serde(default = "default_circuit_open_duration_secs")]
204    pub circuit_breaker_open_duration_secs: u64,
205
206    // ── Keybindings ────────────────────────────────────────────────────
207    /// User-defined keybinding overrides.
208    /// Format: `{ "ActionName": ["Ctrl+x", "Alt+y"] }`
209    /// Actions are matched case-insensitively to the Action enum in oxi-tui.
210    #[serde(default)]
211    pub keybindings: HashMap<String, Vec<String>>,
212}
213
214fn default_theme() -> String {
215    "default".to_string()
216}
217
218fn default_thinking_level() -> ThinkingLevel {
219    ThinkingLevel::Medium
220}
221
222fn default_session_history_size() -> usize {
223    100
224}
225
226fn default_true() -> bool {
227    true
228}
229
230fn default_false() -> bool {
231    false
232}
233
234fn default_circuit_failure_threshold() -> u32 {
235    5
236}
237
238fn default_circuit_open_duration_secs() -> u64 {
239    30
240}
241
242fn default_tool_timeout() -> u64 {
243    120
244}
245
246impl Default for Settings {
247    fn default() -> Self {
248        Self {
249            version: SETTINGS_VERSION,
250            thinking_level: ThinkingLevel::Medium,
251            theme: default_theme(),
252            last_used_model: None,
253            last_used_provider: None,
254            default_model: None,
255            default_provider: None,
256            max_tokens: None,
257            temperature: None,
258            default_temperature: None,
259            max_response_tokens: None,
260            session_history_size: default_session_history_size(),
261            session_dir: None,
262            stream_responses: true,
263            extensions_enabled: true,
264            auto_compaction: true,
265            disabled_tools: Vec::new(),
266            tool_timeout_seconds: default_tool_timeout(),
267            extensions: Vec::new(),
268            skills: Vec::new(),
269            prompts: Vec::new(),
270            themes: Vec::new(),
271            custom_providers: Vec::new(),
272            dynamic_models: HashMap::new(),
273            // Multi-provider routing defaults
274            enable_routing: false,
275            router_profile: None,
276            prefer_cost_efficient: true,
277            fallback_chain: Vec::new(),
278            enable_fallback: true,
279            disable_fallback: false,
280            circuit_breaker_failure_threshold: 5,
281            circuit_breaker_open_duration_secs: 30,
282            keybindings: HashMap::new(),
283        }
284    }
285}
286
287impl Settings {
288    // ── Paths ────────────────────────────────────────────────────────
289
290    /// Get the global settings directory path (`~/.oxi`).
291    pub fn settings_dir() -> Result<PathBuf> {
292        let base = dirs::home_dir().context("Cannot determine home directory")?;
293        Ok(base.join(".oxi"))
294    }
295
296    /// Get the global settings TOML file path (`~/.oxi/settings.toml`).
297    pub fn settings_toml_path() -> Result<PathBuf> {
298        Ok(Self::settings_dir()?.join("settings.toml"))
299    }
300
301    /// Get the global settings JSON file path (`~/.oxi/settings.json`).
302    pub fn settings_json_path() -> Result<PathBuf> {
303        Ok(Self::settings_dir()?.join("settings.json"))
304    }
305
306    /// Get the global settings file path (JSON takes priority).
307    ///
308    /// Returns the path to the settings file that should be used.
309    /// If both JSON and TOML exist, JSON is returned (takes priority).
310    /// If only one exists, that path is returned.
311    /// If neither exists, returns the JSON path by default.
312    pub fn settings_path() -> Result<PathBuf> {
313        let json_path = Self::settings_json_path()?;
314        let toml_path = Self::settings_toml_path()?;
315
316        if json_path.exists() && toml_path.exists() {
317            // Both exist: JSON takes priority
318            tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
319            return Ok(json_path);
320        }
321
322        if json_path.exists() {
323            return Ok(json_path);
324        }
325
326        if toml_path.exists() {
327            return Ok(toml_path);
328        }
329
330        // Neither exists: default to JSON
331        Ok(json_path)
332    }
333
334    /// Get the effective settings file path, preferring the specified format.
335    ///
336    /// If `prefer_json` is true, checks JSON first; otherwise checks TOML first.
337    /// Returns the first existing file, or the preferred path if neither exists.
338    pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
339        let json_path = Self::settings_json_path()?;
340        let toml_path = Self::settings_toml_path()?;
341
342        let (primary, secondary) = if prefer_json {
343            (&json_path, &toml_path)
344        } else {
345            (&toml_path, &json_path)
346        };
347
348        if primary.exists() {
349            return Ok(primary.clone());
350        }
351
352        if secondary.exists() {
353            return Ok(secondary.clone());
354        }
355
356        // Neither exists: return preferred path
357        Ok(primary.clone())
358    }
359
360    /// Detect the settings file format from its path.
361    pub fn detect_format(path: &Path) -> SettingsFormat {
362        match path.extension().and_then(|e| e.to_str()) {
363            Some("json") => SettingsFormat::Json,
364            Some("toml") => SettingsFormat::Toml,
365            _ => SettingsFormat::Json, // Default to JSON for unknown extensions
366        }
367    }
368
369    /// Get the project-local settings file path.
370    ///
371    /// Searches for `.oxi/settings.json` first, then `.oxi/settings.toml`.
372    /// Returns the first one found, or None if neither exists.
373    pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
374        let mut dir = start_dir.to_path_buf();
375        loop {
376            // Check JSON first (priority), then TOML
377            let json_candidate = dir.join(".oxi").join("settings.json");
378            if json_candidate.exists() {
379                return Some(json_candidate);
380            }
381
382            let toml_candidate = dir.join(".oxi").join("settings.toml");
383            if toml_candidate.exists() {
384                return Some(toml_candidate);
385            }
386
387            if !dir.pop() {
388                return None;
389            }
390        }
391    }
392
393    /// Resolve the effective session directory.
394    ///
395    /// Priority: `session_dir` field → `~/.oxi/sessions`.
396    pub fn effective_session_dir(&self) -> Result<PathBuf> {
397        if let Some(ref dir) = self.session_dir {
398            return Ok(dir.clone());
399        }
400        Ok(Self::settings_dir()?.join("sessions"))
401    }
402
403    // ── Loading ──────────────────────────────────────────────────────
404
405    /// Load settings, applying all layers:
406    ///
407    /// 1. Built-in defaults
408    /// 2. Global `~/.oxi/settings.toml`
409    /// 3. Project `.oxi/settings.toml`
410    /// 4. Environment variable overrides
411    ///
412    /// # Examples
413    ///
414    /// ```ignore
415    /// use oxi_cli::Settings;
416    ///
417    /// let settings = Settings::load().expect("Failed to load settings");
418    /// println!("Using model: {}", settings.effective_model(None));
419    /// ```
420    pub fn load() -> Result<Self> {
421        Self::load_from_cwd()
422    }
423
424    /// Load settings with an explicit working directory for project config discovery.
425    pub fn load_from(dir: &std::path::Path) -> Result<Self> {
426        // 1. Start from defaults
427        let mut settings = Settings::default();
428
429        // 2. Layer global config
430        if let Ok(global_path) = Self::settings_path() {
431            if global_path.exists() {
432                settings = Self::layer_file(&settings, &global_path)?;
433            }
434        }
435
436        // 3. Layer project config
437        if let Some(project_path) = Self::find_project_settings(dir) {
438            settings = Self::layer_file(&settings, &project_path)?;
439        }
440
441        // 4. Layer environment variables
442        settings.apply_env();
443
444        // 5. Run migration if needed
445        settings = Self::migrate(settings)?;
446
447        Ok(settings)
448    }
449
450    /// Convenience: load from current working directory.
451    pub fn load_from_cwd() -> Result<Self> {
452        let cwd = env::current_dir().context("Cannot determine current directory")?;
453        Self::load_from(&cwd)
454    }
455
456    /// Parse a settings file (TOML or JSON) and overlay its values onto `base`.
457    ///
458    /// The format is auto-detected based on the file extension.
459    /// Fields present in the file replace those in `base`; absent fields
460    /// are left untouched.
461    fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
462        let content = fs::read_to_string(path)
463            .with_context(|| format!("Failed to read settings from {}", path.display()))?;
464
465        let format = Self::detect_format(path);
466        let overlay: serde_json::Value = match format {
467            SettingsFormat::Toml => {
468                let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
469                    format!("Failed to parse TOML settings from {}", path.display())
470                })?;
471                // Convert TOML to JSON Value for uniform merging
472                toml_value_to_json(toml_value)
473            }
474            SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
475                format!("Failed to parse JSON settings from {}", path.display())
476            })?,
477        };
478
479        // Re-serialize the base to JSON, merge with the overlay, then
480        // deserialize back. This gives correct "only override what's
481        // present" semantics.
482        let base_json =
483            serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
484
485        let merged = merge_json_values(base_json, overlay);
486        let result: Settings =
487            serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
488
489        Ok(result)
490    }
491
492    // ── Environment variables ────────────────────────────────────────
493
494    /// Apply environment variable overrides in-place.
495    ///
496    /// DEPRECATED: Environment variable overrides are being phased out in favor
497    /// of file-based configuration (`~/.oxi/settings.toml`). This method is
498    /// kept for CI/CD compatibility but should not be relied upon for local
499    /// development. Use `oxi config set` or `oxi setup` instead.
500    ///
501    /// Supported variables (CI/CD only):
502    ///
503    /// | Env var                    | Setting                |
504    /// |---------------------------|------------------------|
505    /// | `OXI_MODEL`               | `default_model`        |
506    /// | `OXI_PROVIDER`            | `default_provider`     |
507    /// | `OXI_THINKING`            | `thinking_level`       |
508    /// | `OXI_THEME`               | `theme`                |
509    /// | `OXI_MAX_TOKENS`          | `max_tokens`           |
510    /// | `OXI_TEMPERATURE`         | `default_temperature`  |
511    /// | `OXI_SESSION_DIR`         | `session_dir`          |
512    /// | `OXI_STREAM`              | `stream_responses`     |
513    /// | `OXI_EXTENSIONS_ENABLED`  | `extensions_enabled`   |
514    /// | `OXI_AUTO_COMPACTION`     | `auto_compaction`      |
515    /// | `OXI_TOOL_TIMEOUT`        | `tool_timeout_seconds` |
516    /// | `OXI_DISABLED_TOOLS`      | `disabled_tools`       |
517    #[allow(dead_code)]
518    pub fn apply_env(&mut self) {
519        // No-op: environment variable overrides are disabled.
520        // All configuration should come from settings.toml / settings.json.
521        // This method is kept for backward compatibility but does nothing.
522    }
523
524    /// Build a `Settings` instance from **only** environment variables
525    /// (all other fields stay at defaults).
526    ///
527    /// DEPRECATED: Returns defaults since env overrides are disabled.
528    /// Use `Settings::load()` to load from settings.toml instead.
529    #[allow(dead_code)]
530    pub fn from_env() -> Self {
531        Self::default()
532    }
533
534    // ── Persistence ──────────────────────────────────────────────────
535
536    /// Save settings to the global config file.
537    ///
538    /// Uses the format of the existing file if present, otherwise saves as JSON.
539    /// Preserves backward compatibility with existing TOML files.
540    pub fn save(&self) -> Result<()> {
541        let dir = Self::settings_dir()?;
542        let path = Self::settings_path()?;
543
544        if !dir.exists() {
545            fs::create_dir_all(&dir).with_context(|| {
546                format!("Failed to create settings directory {}", dir.display())
547            })?;
548        }
549
550        let format = Self::detect_format(&path);
551        let content = Self::serialize_for_format(self, format)?;
552
553        // Atomic write: write to temp file first, then rename
554        let tmp_path = path.with_extension("tmp");
555        fs::write(&tmp_path, &content)
556            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
557        fs::rename(&tmp_path, &path)
558            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
559
560        Ok(())
561    }
562
563    /// Save settings to a specific path, using the format determined by the file extension.
564    pub fn save_to(&self, path: &Path) -> Result<()> {
565        if let Some(parent) = path.parent() {
566            if !parent.exists() {
567                fs::create_dir_all(parent)
568                    .with_context(|| format!("Failed to create directory {}", parent.display()))?;
569            }
570        }
571
572        let format = Self::detect_format(path);
573        let content = Self::serialize_for_format(self, format)?;
574
575        // Atomic write
576        let tmp_path = path.with_extension("tmp");
577        fs::write(&tmp_path, &content)
578            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
579        fs::rename(&tmp_path, path)
580            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
581
582        Ok(())
583    }
584
585    /// Save settings to the project-local config file.
586    ///
587    /// Uses the format of the existing file if present, otherwise saves as JSON.
588    pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
589        let dir = project_dir.join(".oxi");
590
591        if !dir.exists() {
592            fs::create_dir_all(&dir).with_context(|| {
593                format!(
594                    "Failed to create project settings directory {}",
595                    dir.display()
596                )
597            })?;
598        }
599
600        // Check if a settings file already exists in project
601        let json_path = dir.join("settings.json");
602        let toml_path = dir.join("settings.toml");
603
604        let path = if json_path.exists() {
605            &json_path
606        } else if toml_path.exists() {
607            &toml_path
608        } else {
609            // Default to JSON for new files
610            &json_path
611        };
612
613        let format = Self::detect_format(path);
614        let content = Self::serialize_for_format(self, format)?;
615
616        // Atomic write
617        let tmp_path = path.with_extension("tmp");
618        fs::write(&tmp_path, &content)
619            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
620        fs::rename(&tmp_path, path)
621            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
622
623        Ok(())
624    }
625
626    /// Serialize settings to a string in the specified format.
627    pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
628        match format {
629            SettingsFormat::Toml => {
630                toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
631            }
632            SettingsFormat::Json => serde_json::to_string_pretty(settings)
633                .context("Failed to serialize settings to JSON"),
634        }
635    }
636
637    /// Parse settings from a string in the specified format.
638    pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
639        match format {
640            SettingsFormat::Toml => {
641                toml::from_str(content).context("Failed to parse TOML settings")
642            }
643            SettingsFormat::Json => {
644                serde_json::from_str(content).context("Failed to parse JSON settings")
645            }
646        }
647    }
648
649    // ── CLI overrides ────────────────────────────────────────────────
650
651    /// Merge with CLI arguments (CLI takes precedence).
652    ///
653    /// # Arguments
654    ///
655    /// * `model` — CLI-specified model override
656    /// * `provider` — CLI-specified provider override
657    /// * `enable_routing` — CLI-specified enable_routing override
658    /// * `prefer_cost_efficient` — CLI-specified prefer_cost_efficient override
659    /// * `fallback_chain` — CLI-specified fallback chain override
660    /// * `disable_fallback` — CLI-specified disable_fallback override
661    pub fn merge_cli(
662        &mut self,
663        model: Option<String>,
664        provider: Option<String>,
665        enable_routing: Option<bool>,
666        prefer_cost_efficient: Option<bool>,
667        fallback_chain: Option<Vec<String>>,
668        disable_fallback: Option<bool>,
669    ) {
670        if let Some(m) = model {
671            self.last_used_model = Some(m);
672        }
673        if let Some(p) = provider {
674            self.last_used_provider = Some(p);
675        }
676        if let Some(r) = enable_routing {
677            self.enable_routing = r;
678        }
679        if let Some(p) = prefer_cost_efficient {
680            self.prefer_cost_efficient = p;
681        }
682        if let Some(fc) = fallback_chain {
683            if !fc.is_empty() {
684                self.fallback_chain = fc;
685            }
686        }
687        if let Some(df) = disable_fallback {
688            self.disable_fallback = df;
689            // If disable_fallback is true, disable fallback
690            if df {
691                self.enable_fallback = false;
692            }
693        }
694    }
695
696    /// Get the effective model ID (provider/model format).
697    /// Returns None if no model is configured.
698    pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
699        cli_model.map(String::from).or_else(|| {
700            // Reconstruct full model ID from separate fields.
701            // Handles both cases:
702            //   - last_used_model = "anthropic/claude-sonnet-4" (full ID, stored by save_last_used)
703            //   - last_used_model = "claude-sonnet-4" + last_used_provider = "anthropic" (split)
704            let model = self.last_used_model.as_ref()?;
705            if model.contains('/') {
706                // Already a full model ID
707                Some(model.clone())
708            } else if let Some(ref provider) = self.last_used_provider {
709                // Reconstruct from separate fields
710                Some(format!("{}/{}", provider, model))
711            } else {
712                Some(model.clone())
713            }
714        })
715    }
716
717    /// Get the effective provider.
718    /// Returns None if no provider is configured.
719    pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
720        cli_provider
721            .map(String::from)
722            .or_else(|| self.last_used_provider.clone())
723    }
724
725    /// Get the effective temperature, preferring `default_temperature` (f64)
726    /// over `temperature` (f32), falling back to `None`.
727    pub fn effective_temperature(&self) -> Option<f64> {
728        self.default_temperature
729            .or(self.temperature.map(|t| t as f64))
730    }
731
732    /// Get the effective max tokens, preferring `max_response_tokens` (usize)
733    /// over `max_tokens` (u32), falling back to `None`.
734    pub fn effective_max_tokens(&self) -> Option<usize> {
735        self.max_response_tokens
736            .or(self.max_tokens.map(|t| t as usize))
737    }
738
739    /// Get the configured router profile name.
740    pub fn router_profile(&self) -> Option<&str> {
741        self.router_profile.as_deref()
742    }
743
744    // ── Theme persistence ─────────────────────────────────────────────
745
746    /// Save the last used model/provider and persist to disk.
747    ///
748    /// Splits the model_id on first `/` to store provider and model separately.
749    pub fn save_last_used(model_id: &str) {
750        if let Ok(mut settings) = Self::load() {
751            if let Some((provider, model)) = model_id.split_once('/') {
752                settings.last_used_provider = Some(provider.to_string());
753                settings.last_used_model = Some(model.to_string());
754            } else {
755                settings.last_used_model = Some(model_id.to_string());
756            }
757            let _ = settings.save();
758        }
759    }
760
761    /// Save the current theme to settings and persist to disk.
762    pub fn save_theme(&mut self, name: &str) -> Result<()> {
763        self.theme = name.to_string();
764        self.save()
765    }
766
767    /// Get the theme name from settings, returning a default if not set.
768    pub fn get_theme_name(&self) -> String {
769        if self.theme.is_empty() || self.theme == "default" {
770            "oxi_dark".to_string()
771        } else {
772            self.theme.clone()
773        }
774    }
775
776    // ── Migration ────────────────────────────────────────────────────
777
778    /// Migrate settings from an older format version to the current one.
779    ///
780    /// Currently handles:
781    /// - Version 0 → Version 2 (adds JSON support, version bump)
782    /// - Version 1 → Version 2 (adds JSON support)
783    fn migrate(settings: Settings) -> Result<Settings> {
784        let mut settings = settings;
785
786        match settings.version {
787            SETTINGS_VERSION => {
788                // Already current — nothing to do.
789            }
790            0 => {
791                // Version 0 = pre-versioning config.
792                // Add any defaults that were introduced in version 1.
793                if settings.tool_timeout_seconds == 0 {
794                    settings.tool_timeout_seconds = default_tool_timeout();
795                }
796                settings.version = SETTINGS_VERSION;
797
798                tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
799            }
800            1 | 2 => {
801                // Version 1/2 → 4: dynamic_models field added + model/provider split.
802                settings.version = SETTINGS_VERSION;
803                tracing::info!(
804                    "Migrated settings from version {} to {}",
805                    settings.version,
806                    SETTINGS_VERSION
807                );
808            }
809            3 => {
810                // Version 3 → 4: migrate default_model → last_used_model.
811                if let Some(model) = settings.default_model.take() {
812                    if let Some((provider, model_name)) = model.split_once('/') {
813                        settings.last_used_provider = Some(provider.to_string());
814                        settings.last_used_model = Some(model_name.to_string());
815                    } else {
816                        settings.last_used_model = Some(model);
817                    }
818                }
819                settings.version = SETTINGS_VERSION;
820                tracing::info!(
821                    "Migrated settings from version 3 to {} (default_model → last_used_model)",
822                    SETTINGS_VERSION
823                );
824            }
825            v if v > SETTINGS_VERSION => {
826                // Future version — we don't know how to downgrade.
827                anyhow::bail!(
828                    "Settings version {} is newer than supported version {}. \
829                     Please update oxi.",
830                    v,
831                    SETTINGS_VERSION
832                );
833            }
834            v => {
835                // Unknown old version — best-effort migration.
836                tracing::warn!(
837                    "Unknown settings version {}, attempting migration to {}",
838                    v,
839                    SETTINGS_VERSION
840                );
841                settings.version = SETTINGS_VERSION;
842            }
843        }
844
845        Ok(settings)
846    }
847}
848
849// ── Settings format detection ──────────────────────────────────────
850
851/// Supported settings file formats.
852#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
853pub enum SettingsFormat {
854    /// JSON format.
855    #[default]
856    Json,
857    /// TOML format.
858    Toml,
859}
860
861impl SettingsFormat {
862    /// Get the file extension for this format.
863    pub fn extension(&self) -> &'static str {
864        match self {
865            SettingsFormat::Json => "json",
866            SettingsFormat::Toml => "toml",
867        }
868    }
869}
870
871// ── JSON/TOML conversion helpers ────────────────────────────────────
872
873/// Convert a TOML Value to a serde_json::Value.
874fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
875    match toml {
876        toml::Value::String(s) => serde_json::Value::String(s),
877        toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
878        toml::Value::Float(f) => serde_json::Number::from_f64(f)
879            .map(serde_json::Value::Number)
880            .unwrap_or(serde_json::Value::Null),
881        toml::Value::Boolean(b) => serde_json::Value::Bool(b),
882        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
883        toml::Value::Array(arr) => {
884            serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
885        }
886        toml::Value::Table(table) => {
887            let obj = table
888                .into_iter()
889                .map(|(k, v)| (k, toml_value_to_json(v)))
890                .collect();
891            serde_json::Value::Object(obj)
892        }
893    }
894}
895
896/// Deep merge two JSON values. The second value overrides the first.
897fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
898    match (base, override_) {
899        // If either is not an object, the override wins
900        (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
901            let mut result = base_map;
902            for (key, override_value) in override_map {
903                let base_value = result.remove(&key);
904                let merged = match base_value {
905                    Some(base_v) => merge_json_values(base_v, override_value),
906                    None => override_value,
907                };
908                result.insert(key, merged);
909            }
910            serde_json::Value::Object(result)
911        }
912        // Override wins for non-objects
913        (_, override_) => override_,
914    }
915}
916
917/// Parse a thinking level from a string.
918pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
919    match s.to_lowercase().as_str() {
920        "off" | "none" => Some(ThinkingLevel::Off),
921        "minimal" => Some(ThinkingLevel::Minimal),
922        "low" => Some(ThinkingLevel::Low),
923        "medium" | "standard" => Some(ThinkingLevel::Medium),
924        "high" | "thorough" => Some(ThinkingLevel::High),
925        "xhigh" => Some(ThinkingLevel::XHigh),
926        _ => None,
927    }
928}
929
930/// Parse a boolean-like string (`"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"`).
931#[allow(dead_code)]
932fn parse_boolish(s: &str) -> Result<bool> {
933    match s.to_lowercase().as_str() {
934        "true" | "1" | "yes" | "on" => Ok(true),
935        "false" | "0" | "no" | "off" => Ok(false),
936        _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
937    }
938}
939
940#[cfg(test)]
941mod tests {
942    use super::*;
943    use std::io::Write as IoWrite;
944    use std::sync::Mutex;
945
946    /// Global lock to serialize all tests that manipulate process-wide env vars.
947    static ENV_LOCK: Mutex<()> = Mutex::new(());
948
949    /// RAII guard that removes listed env vars on creation and restores them on drop.
950    /// This prevents parallel test races where one test sets an env var that leaks into another.
951    struct EnvGuard {
952        saved: Vec<(String, Option<String>)>,
953    }
954
955    impl EnvGuard {
956        fn new(vars: &[&str]) -> Self {
957            let saved = vars
958                .iter()
959                .map(|&name| {
960                    let old = env::var(name).ok();
961                    env::remove_var(name);
962                    (name.to_string(), old)
963                })
964                .collect();
965            Self { saved }
966        }
967    }
968
969    impl Drop for EnvGuard {
970        fn drop(&mut self) {
971            for (name, old) in self.saved.drain(..) {
972                match old {
973                    Some(val) => env::set_var(&name, val),
974                    None => env::remove_var(&name),
975                }
976            }
977        }
978    }
979
980    // ── Struct tests ─────────────────────────────────────────────────
981
982    #[test]
983    fn test_default_settings() {
984        let settings = Settings::default();
985        assert_eq!(settings.version, SETTINGS_VERSION);
986        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
987        assert_eq!(settings.theme, "default");
988        assert!(settings.last_used_model.is_none());
989        assert!(settings.last_used_provider.is_none());
990        assert!(settings.extensions_enabled);
991        assert!(settings.auto_compaction);
992        assert_eq!(settings.tool_timeout_seconds, 120);
993        assert!(settings.stream_responses);
994    }
995
996    #[test]
997    fn test_merge_cli() {
998        let mut settings = Settings::default();
999        settings.last_used_model = Some("gpt-4o".to_string());
1000
1001        settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1002        assert_eq!(settings.last_used_model, Some("claude".to_string()));
1003
1004        settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1005        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1006
1007        // Test routing flags
1008        settings.merge_cli(
1009            None,
1010            None,
1011            Some(true),
1012            Some(false),
1013            Some(vec!["openai/gpt-4o".to_string()]),
1014            Some(false),
1015        );
1016        assert!(settings.enable_routing);
1017        assert!(!settings.prefer_cost_efficient);
1018        assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1019        assert!(!settings.disable_fallback);
1020
1021        // Test disable_fallback sets enable_fallback to false
1022        let mut settings2 = Settings::default();
1023        settings2.merge_cli(None, None, None, None, None, Some(true));
1024        assert!(settings2.disable_fallback);
1025        assert!(!settings2.enable_fallback);
1026    }
1027
1028    // ── Layered loading ──────────────────────────────────────────────
1029
1030    #[test]
1031    fn test_layer_file_overrides() {
1032        let base = Settings::default();
1033
1034        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1035        let toml_content = r#"
1036last_used_model = "openai/gpt-4o"
1037theme = "dracula"
1038"#;
1039        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1040
1041        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1042        assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1043        assert_eq!(merged.theme, "dracula");
1044        // Unchanged fields retain defaults
1045        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1046        assert!(merged.extensions_enabled);
1047    }
1048
1049    #[test]
1050    fn test_layer_file_preserves_unset() {
1051        let mut base = Settings::default();
1052        base.last_used_provider = Some("deepseek".to_string());
1053
1054        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1055        // Only override theme — provider should remain
1056        let toml_content = "theme = \"monokai\"\n";
1057        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1058
1059        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1060        assert_eq!(merged.theme, "monokai");
1061        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1062    }
1063
1064    #[test]
1065    fn test_load_from_dir_with_project_config() {
1066        let _guard = EnvGuard::new(&[
1067            "OXI_MODEL",
1068            "OXI_PROVIDER",
1069            "OXI_THEME",
1070            "OXI_TOOL_TIMEOUT",
1071            "OXI_TEMPERATURE",
1072            "OXI_MAX_TOKENS",
1073            "OXI_SESSION_DIR",
1074            "OXI_STREAM",
1075            "OXI_EXTENSIONS_ENABLED",
1076        ]);
1077        let tmp = tempfile::tempdir().unwrap();
1078        let oxi_dir = tmp.path().join(".oxi");
1079        fs::create_dir_all(&oxi_dir).unwrap();
1080        let settings_path = oxi_dir.join("settings.toml");
1081        // Write v3 format: default_model contains "provider/model"
1082        fs::write(
1083            &settings_path,
1084            "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1085        )
1086        .unwrap();
1087
1088        let settings = Settings::load_from(tmp.path()).unwrap();
1089        // Migration moves default_model → last_used_model
1090        assert_eq!(
1091            settings.last_used_model,
1092            Some("gemini-2.0-flash".to_string())
1093        );
1094        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1095    }
1096
1097    #[test]
1098    fn test_load_from_dir_no_config() {
1099        // Clean env vars that load_from() reads via apply_env()
1100        let _guard = EnvGuard::new(&[
1101            "OXI_MODEL",
1102            "OXI_PROVIDER",
1103            "OXI_THEME",
1104            "OXI_TOOL_TIMEOUT",
1105            "OXI_TEMPERATURE",
1106            "OXI_MAX_TOKENS",
1107            "OXI_SESSION_DIR",
1108            "OXI_STREAM",
1109            "OXI_EXTENSIONS_ENABLED",
1110        ]);
1111        let tmp = tempfile::tempdir().unwrap();
1112        let settings = Settings::load_from(tmp.path()).unwrap();
1113        // Falls back to defaults (may include global ~/.oxi/settings)
1114        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1115    }
1116
1117    // ── Environment variables ────────────────────────────────────────
1118
1119    #[test]
1120    fn test_from_env() {
1121        // NOTE: Environment variable overrides are disabled.
1122        // from_env() returns defaults only.
1123        let _guard = EnvGuard::new(&[
1124            // no env vars to clear
1125            "OXI_MODEL",
1126            "OXI_THEME",
1127            "OXI_TOOL_TIMEOUT",
1128            "OXI_PROVIDER",
1129            "OXI_DEFAULT_MODEL",
1130        ]);
1131
1132        let settings = Settings::from_env();
1133        // All fields should be at defaults since env overrides are disabled
1134        assert_eq!(settings.last_used_model, None);
1135        assert_eq!(settings.theme, "default");
1136        assert_eq!(settings.tool_timeout_seconds, 120);
1137    }
1138
1139    #[test]
1140    fn test_apply_env_boolish() {
1141        // NOTE: Environment variable overrides are disabled.
1142        // apply_env() is a no-op.
1143        let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1144        env::set_var("OXI_STREAM", "false");
1145        env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1146
1147        let mut settings = Settings::default();
1148        settings.apply_env();
1149        // Since env overrides are disabled, values stay at defaults
1150        assert!(settings.stream_responses); // default is true
1151        assert!(settings.extensions_enabled); // default is true
1152    }
1153
1154    #[test]
1155    fn test_apply_env_temperature() {
1156        // NOTE: Environment variable overrides are disabled.
1157        let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1158        env::set_var("OXI_TEMPERATURE", "0.7");
1159
1160        let mut settings = Settings::default();
1161        settings.apply_env();
1162        // Since env overrides are disabled, temperature stays at None
1163        assert_eq!(settings.default_temperature, None);
1164    }
1165
1166    #[test]
1167    fn test_env_does_not_override_when_unset() {
1168        let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1169        let settings = Settings::from_env();
1170        assert!(settings.last_used_model.is_none());
1171        assert!(settings.last_used_provider.is_none());
1172    }
1173
1174    #[test]
1175    fn test_parse_thinking_level() {
1176        assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1177        assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1178        assert_eq!(
1179            parse_thinking_level("MINIMAL"),
1180            Some(ThinkingLevel::Minimal)
1181        );
1182        assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1183        assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1184        assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1185        assert_eq!(
1186            parse_thinking_level("Standard"),
1187            Some(ThinkingLevel::Medium)
1188        );
1189        assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1190        assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1191        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1192        assert_eq!(parse_thinking_level("invalid"), None);
1193    }
1194
1195    #[test]
1196    fn test_parse_boolish() {
1197        assert!(parse_boolish("true").unwrap());
1198        assert!(parse_boolish("1").unwrap());
1199        assert!(parse_boolish("yes").unwrap());
1200        assert!(parse_boolish("ON").unwrap());
1201        assert!(!parse_boolish("false").unwrap());
1202        assert!(!parse_boolish("0").unwrap());
1203        assert!(!parse_boolish("no").unwrap());
1204        assert!(!parse_boolish("OFF").unwrap());
1205        assert!(parse_boolish("maybe").is_err());
1206    }
1207
1208    // ── Effective accessors ──────────────────────────────────────────
1209
1210    #[test]
1211    fn test_effective_model_returns_last_used() {
1212        let mut settings = Settings::default();
1213        settings.last_used_model = Some("openai/gpt-4o".to_string());
1214        assert_eq!(
1215            settings.effective_model(None),
1216            Some("openai/gpt-4o".to_string())
1217        );
1218    }
1219
1220    #[test]
1221    fn test_effective_model_cli_overrides() {
1222        let mut settings = Settings::default();
1223        settings.last_used_model = Some("openai/gpt-4o".to_string());
1224        assert_eq!(
1225            settings.effective_model(Some("anthropic/claude-3")),
1226            Some("anthropic/claude-3".to_string())
1227        );
1228    }
1229
1230    #[test]
1231    fn test_effective_model_none_when_unset() {
1232        let settings = Settings::default();
1233        assert_eq!(settings.effective_model(None), None);
1234    }
1235
1236    #[test]
1237    fn test_effective_model_falls_back_to_last_used() {
1238        let mut settings = Settings::default();
1239        settings.last_used_model = Some("anthropic/claude-3".to_string());
1240        assert_eq!(
1241            settings.effective_model(None),
1242            Some("anthropic/claude-3".to_string())
1243        );
1244    }
1245
1246    #[test]
1247    fn test_effective_model_returns_none_when_nothing_set() {
1248        let settings = Settings::default();
1249        assert_eq!(settings.effective_model(None), None);
1250    }
1251
1252    #[test]
1253    fn test_effective_temperature_prefers_f64() {
1254        let mut settings = Settings::default();
1255        settings.temperature = Some(0.5);
1256        settings.default_temperature = Some(0.7);
1257        assert_eq!(settings.effective_temperature(), Some(0.7));
1258    }
1259
1260    #[test]
1261    fn test_effective_temperature_falls_back_to_f32() {
1262        let mut settings = Settings::default();
1263        settings.temperature = Some(0.5);
1264        assert_eq!(settings.effective_temperature(), Some(0.5));
1265    }
1266
1267    #[test]
1268    fn test_effective_max_tokens_prefers_usize() {
1269        let mut settings = Settings::default();
1270        settings.max_tokens = Some(1024);
1271        settings.max_response_tokens = Some(4096);
1272        assert_eq!(settings.effective_max_tokens(), Some(4096));
1273    }
1274
1275    #[test]
1276    fn test_effective_max_tokens_falls_back_to_u32() {
1277        let mut settings = Settings::default();
1278        settings.max_tokens = Some(1024);
1279        assert_eq!(settings.effective_max_tokens(), Some(1024));
1280    }
1281
1282    // ── Session dir ──────────────────────────────────────────────────
1283
1284    #[test]
1285    fn test_effective_session_dir_default() {
1286        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1287        let settings = Settings::default();
1288        let dir = settings.effective_session_dir().unwrap();
1289        assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1290    }
1291
1292    #[test]
1293    fn test_effective_session_dir_from_field() {
1294        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1295        let mut settings = Settings::default();
1296        settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1297        assert_eq!(
1298            settings.effective_session_dir().unwrap(),
1299            PathBuf::from("/tmp/oxi-sessions")
1300        );
1301    }
1302
1303    #[test]
1304    fn test_effective_session_dir_env_disabled() {
1305        // NOTE: Environment variable overrides are disabled.
1306        // OXI_SESSION_DIR is ignored; effective_session_dir() returns the field value (or default).
1307        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1308        env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
1309        let settings = Settings::default();
1310        // Env is ignored, so it should use the default path, not /tmp/env-sessions
1311        let dir = settings.effective_session_dir().unwrap();
1312        assert!(
1313            dir.ends_with("sessions"),
1314            "expected default sessions dir, got: {:?}",
1315            dir
1316        );
1317    }
1318
1319    // ── Migration ────────────────────────────────────────────────────
1320
1321    #[test]
1322    fn test_migration_v0_to_v1() {
1323        let mut settings = Settings::default();
1324        settings.version = 0;
1325        settings.tool_timeout_seconds = 0; // v0 might not have this field
1326
1327        let migrated = Settings::migrate(settings).unwrap();
1328        assert_eq!(migrated.version, SETTINGS_VERSION);
1329        assert_eq!(migrated.tool_timeout_seconds, 120);
1330    }
1331
1332    #[test]
1333    fn test_migration_already_current() {
1334        let settings = Settings::default();
1335        let migrated = Settings::migrate(settings).unwrap();
1336        assert_eq!(migrated.version, SETTINGS_VERSION);
1337    }
1338
1339    #[test]
1340    fn test_migration_v3_to_v4_splits_model() {
1341        let mut settings = Settings::default();
1342        settings.version = 3;
1343        settings.default_model = Some("openai/gpt-4o".to_string());
1344        settings.default_provider = None;
1345
1346        let migrated = Settings::migrate(settings).unwrap();
1347        assert_eq!(migrated.version, SETTINGS_VERSION);
1348        assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1349        assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1350    }
1351
1352    #[test]
1353    fn test_migration_v3_no_slash_keeps_model() {
1354        let mut settings = Settings::default();
1355        settings.version = 3;
1356        settings.default_model = Some("bare-model-name".to_string());
1357
1358        let migrated = Settings::migrate(settings).unwrap();
1359        assert_eq!(migrated.version, SETTINGS_VERSION);
1360        assert_eq!(
1361            migrated.last_used_model,
1362            Some("bare-model-name".to_string())
1363        );
1364    }
1365
1366    #[test]
1367    fn test_migration_future_version_fails() {
1368        let mut settings = Settings::default();
1369        settings.version = 9999;
1370        assert!(Settings::migrate(settings).is_err());
1371    }
1372
1373    // ── Persistence ──────────────────────────────────────────────────
1374
1375    #[test]
1376    fn test_save_and_load_roundtrip() {
1377        let tmp = tempfile::tempdir().unwrap();
1378        let settings_path = tmp.path().join("settings.toml");
1379
1380        let mut original = Settings::default();
1381        original.last_used_model = Some("gpt-4o".to_string());
1382        original.last_used_provider = Some("openai".to_string());
1383        original.theme = "dracula".to_string();
1384        original.tool_timeout_seconds = 60;
1385
1386        // Serialize
1387        let content = toml::to_string_pretty(&original).unwrap();
1388        fs::write(&settings_path, &content).unwrap();
1389
1390        // Deserialize
1391        let loaded_content = fs::read_to_string(&settings_path).unwrap();
1392        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1393
1394        assert_eq!(loaded.last_used_model, original.last_used_model);
1395        assert_eq!(loaded.theme, original.theme);
1396        assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1397    }
1398
1399    #[test]
1400    fn test_toml_roundtrip_preserves_new_fields() {
1401        let mut settings = Settings::default();
1402        settings.default_temperature = Some(0.8);
1403        settings.max_response_tokens = Some(8192);
1404        settings.auto_compaction = false;
1405        settings.extensions_enabled = false;
1406        settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1407
1408        let toml_str = toml::to_string_pretty(&settings).unwrap();
1409        let parsed: Settings = toml::from_str(&toml_str).unwrap();
1410
1411        assert_eq!(parsed.default_temperature, Some(0.8));
1412        assert_eq!(parsed.max_response_tokens, Some(8192));
1413        assert!(!parsed.auto_compaction);
1414        assert!(!parsed.extensions_enabled);
1415        assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1416    }
1417
1418    // ── JSON format tests ──────────────────────────────────────────────
1419
1420    #[test]
1421    fn test_json_roundtrip() {
1422        let mut settings = Settings::default();
1423        settings.last_used_model = Some("gpt-4o".to_string());
1424        settings.last_used_provider = Some("openai".to_string());
1425        settings.theme = "dracula".to_string();
1426        settings.tool_timeout_seconds = 60;
1427        settings.default_temperature = Some(0.8);
1428        settings.max_response_tokens = Some(8192);
1429
1430        let json_str = serde_json::to_string_pretty(&settings).unwrap();
1431        let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1432
1433        assert_eq!(parsed.last_used_model, settings.last_used_model);
1434        assert_eq!(parsed.theme, settings.theme);
1435        assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1436        assert_eq!(parsed.default_temperature, settings.default_temperature);
1437        assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1438    }
1439
1440    #[test]
1441    fn test_json_serialize_for_format() {
1442        let mut settings = Settings::default();
1443        settings.last_used_model = Some("claude-3".to_string());
1444        settings.last_used_provider = Some("anthropic".to_string());
1445        settings.thinking_level = ThinkingLevel::Minimal;
1446
1447        let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1448        let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1449
1450        assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1451        assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1452    }
1453
1454    #[test]
1455    fn test_toml_serialize_for_format() {
1456        let mut settings = Settings::default();
1457        settings.last_used_model = Some("gemini-pro".to_string());
1458        settings.last_used_provider = Some("google".to_string());
1459        settings.thinking_level = ThinkingLevel::High;
1460
1461        let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1462        let parsed: Settings = toml::from_str(&toml_content).unwrap();
1463
1464        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1465        assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1466    }
1467
1468    #[test]
1469    fn test_parse_from_str_json() {
1470        let json_content = r#"{
1471            "last_used_model": "gpt-4",
1472            "last_used_provider": "openai",
1473            "theme": "nord",
1474            "tool_timeout_seconds": 90
1475        }"#;
1476
1477        let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1478        assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1479        assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1480        assert_eq!(settings.theme, "nord");
1481        assert_eq!(settings.tool_timeout_seconds, 90);
1482        // Unchanged fields retain defaults
1483        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1484        assert!(settings.extensions_enabled);
1485    }
1486
1487    #[test]
1488    fn test_parse_from_str_toml() {
1489        let toml_content = r#"
1490last_used_model = "claude-opus"
1491last_used_provider = "anthropic"
1492theme = "monokai"
1493tool_timeout_seconds = 45
1494"#;
1495
1496        let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1497        assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1498        assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1499        assert_eq!(settings.theme, "monokai");
1500        assert_eq!(settings.tool_timeout_seconds, 45);
1501        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1502    }
1503
1504    #[test]
1505    fn test_layer_file_json() {
1506        let base = Settings::default();
1507
1508        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1509        let json_content = r#"{
1510            "last_used_model": "gpt-4o",
1511            "last_used_provider": "openai",
1512            "theme": "dracula",
1513            "auto_compaction": false
1514        }"#;
1515        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1516
1517        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1518        assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1519        assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1520        assert_eq!(merged.theme, "dracula");
1521        assert!(!merged.auto_compaction);
1522        // Unchanged fields retain defaults
1523        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1524        assert!(merged.extensions_enabled);
1525        assert_eq!(merged.tool_timeout_seconds, 120);
1526    }
1527
1528    #[test]
1529    fn test_layer_file_json_preserves_unset() {
1530        let mut base = Settings::default();
1531        base.last_used_provider = Some("deepseek".to_string());
1532
1533        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1534        let json_content = r#"{ "theme": "nord" }"#;
1535        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1536
1537        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1538        assert_eq!(merged.theme, "nord");
1539        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1540    }
1541
1542    #[test]
1543    fn test_save_to_json() {
1544        let tmp = tempfile::tempdir().unwrap();
1545        let settings_path = tmp.path().join("settings.json");
1546
1547        let mut settings = Settings::default();
1548        settings.last_used_model = Some("gpt-4o".to_string());
1549        settings.last_used_provider = Some("openai".to_string());
1550        settings.theme = "dracula".to_string();
1551        settings.tool_timeout_seconds = 60;
1552
1553        settings.save_to(&settings_path).unwrap();
1554
1555        // Verify it's valid JSON
1556        let content = fs::read_to_string(&settings_path).unwrap();
1557        let parsed: Settings = serde_json::from_str(&content).unwrap();
1558        assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1559        assert_eq!(parsed.theme, "dracula");
1560        assert_eq!(parsed.tool_timeout_seconds, 60);
1561    }
1562
1563    #[test]
1564    fn test_save_to_toml() {
1565        let tmp = tempfile::tempdir().unwrap();
1566        let settings_path = tmp.path().join("settings.toml");
1567
1568        let mut settings = Settings::default();
1569        settings.last_used_model = Some("gemini-pro".to_string());
1570        settings.last_used_provider = Some("google".to_string());
1571        settings.theme = "monokai".to_string();
1572        settings.tool_timeout_seconds = 90;
1573
1574        settings.save_to(&settings_path).unwrap();
1575
1576        // Verify it's valid TOML
1577        let content = fs::read_to_string(&settings_path).unwrap();
1578        let parsed: Settings = toml::from_str(&content).unwrap();
1579        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1580        assert_eq!(parsed.theme, "monokai");
1581        assert_eq!(parsed.tool_timeout_seconds, 90);
1582    }
1583
1584    #[test]
1585    fn test_load_from_dir_with_json_project_config() {
1586        let _guard = EnvGuard::new(&[
1587            "OXI_MODEL",
1588            "OXI_PROVIDER",
1589            "OXI_THEME",
1590            "OXI_TOOL_TIMEOUT",
1591            "OXI_TEMPERATURE",
1592            "OXI_MAX_TOKENS",
1593            "OXI_SESSION_DIR",
1594            "OXI_STREAM",
1595            "OXI_EXTENSIONS_ENABLED",
1596        ]);
1597        let tmp = tempfile::tempdir().unwrap();
1598        let oxi_dir = tmp.path().join(".oxi");
1599        fs::create_dir_all(&oxi_dir).unwrap();
1600        let settings_path = oxi_dir.join("settings.json");
1601        // v3 format: default_model has provider/model
1602        let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1603        fs::write(&settings_path, json_content).unwrap();
1604
1605        let settings = Settings::load_from(tmp.path()).unwrap();
1606        // Migration splits provider from model
1607        assert_eq!(
1608            settings.last_used_model,
1609            Some("gemini-2.0-flash".to_string())
1610        );
1611        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1612    }
1613
1614    #[test]
1615    fn test_find_project_settings_json_priority() {
1616        let tmp = tempfile::tempdir().unwrap();
1617        let oxi_dir = tmp.path().join(".oxi");
1618        fs::create_dir_all(&oxi_dir).unwrap();
1619
1620        // Create both files
1621        let json_path = oxi_dir.join("settings.json");
1622        let toml_path = oxi_dir.join("settings.toml");
1623        fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1624        fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1625
1626        // JSON takes priority
1627        let found = Settings::find_project_settings(tmp.path());
1628        assert!(found.is_some());
1629        assert_eq!(
1630            found.unwrap().file_name().unwrap().to_str().unwrap(),
1631            "settings.json"
1632        );
1633    }
1634
1635    #[test]
1636    fn test_find_project_settings_json_only() {
1637        let tmp = tempfile::tempdir().unwrap();
1638        let oxi_dir = tmp.path().join(".oxi");
1639        fs::create_dir_all(&oxi_dir).unwrap();
1640
1641        let json_path = oxi_dir.join("settings.json");
1642        fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1643
1644        let found = Settings::find_project_settings(tmp.path());
1645        assert!(found.is_some());
1646        assert_eq!(
1647            found.unwrap().file_name().unwrap().to_str().unwrap(),
1648            "settings.json"
1649        );
1650    }
1651
1652    #[test]
1653    fn test_find_project_settings_toml_fallback() {
1654        let tmp = tempfile::tempdir().unwrap();
1655        let oxi_dir = tmp.path().join(".oxi");
1656        fs::create_dir_all(&oxi_dir).unwrap();
1657
1658        let toml_path = oxi_dir.join("settings.toml");
1659        fs::write(&toml_path, r#"theme = "test""#).unwrap();
1660
1661        let found = Settings::find_project_settings(tmp.path());
1662        assert!(found.is_some());
1663        assert_eq!(
1664            found.unwrap().file_name().unwrap().to_str().unwrap(),
1665            "settings.toml"
1666        );
1667    }
1668
1669    #[test]
1670    fn test_detect_format() {
1671        let json_path = PathBuf::from("/test/settings.json");
1672        let toml_path = PathBuf::from("/test/settings.toml");
1673        let unknown_path = PathBuf::from("/test/settings");
1674
1675        assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1676        assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1677        assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1678        // Default
1679    }
1680
1681    #[test]
1682    fn test_settings_format_extension() {
1683        assert_eq!(SettingsFormat::Json.extension(), "json");
1684        assert_eq!(SettingsFormat::Toml.extension(), "toml");
1685    }
1686
1687    #[test]
1688    fn test_layer_json_over_toml() {
1689        // Test that when loading, JSON takes priority over TOML
1690        let tmp = tempfile::tempdir().unwrap();
1691        let oxi_dir = tmp.path().join(".oxi");
1692        fs::create_dir_all(&oxi_dir).unwrap();
1693
1694        let json_path = oxi_dir.join("settings.json");
1695        let toml_path = oxi_dir.join("settings.toml");
1696
1697        // JSON has model set to "json-model"
1698        fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1699        // TOML has model set to "toml-model"
1700        fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1701
1702        // JSON takes priority
1703        let settings = Settings::load_from(tmp.path()).unwrap();
1704        assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1705    }
1706
1707    #[test]
1708    fn test_mixed_format_loading() {
1709        // Test loading a TOML file through the generic layer_file
1710        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1711        let toml_content = r#"
1712last_used_model = "loaded-via-toml"
1713theme = "loaded-theme"
1714stream_responses = false
1715"#;
1716        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1717
1718        let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1719        assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
1720        assert_eq!(merged.theme, "loaded-theme");
1721        assert!(!merged.stream_responses);
1722    }
1723
1724    #[test]
1725    fn test_merge_json_values() {
1726        let base = serde_json::json!({
1727            "version": 1,
1728            "theme": "default",
1729            "extensions": ["ext1"],
1730            "nested": {
1731                "a": 1,
1732                "b": 2
1733            }
1734        });
1735
1736        let override_ = serde_json::json!({
1737            "version": 2,
1738            "theme": "dark",
1739            "extensions": ["ext2"],
1740            "nested": {
1741                "b": 20,
1742                "c": 30
1743            }
1744        });
1745
1746        let merged = merge_json_values(base, override_);
1747
1748        assert_eq!(merged["version"], 2);
1749        assert_eq!(merged["theme"], "dark");
1750        // Arrays are replaced, not merged
1751        assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1752        // Nested objects are deeply merged
1753        assert_eq!(merged["nested"]["a"], 1);
1754        assert_eq!(merged["nested"]["b"], 20);
1755        assert_eq!(merged["nested"]["c"], 30);
1756    }
1757
1758    #[test]
1759    fn test_save_project_preserves_existing_format() {
1760        let tmp = tempfile::tempdir().unwrap();
1761        let oxi_dir = tmp.path().join(".oxi");
1762        fs::create_dir_all(&oxi_dir).unwrap();
1763
1764        // Create existing TOML file
1765        let toml_path = oxi_dir.join("settings.toml");
1766        fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1767
1768        let mut settings = Settings::default();
1769        settings.theme = "new-theme".to_string();
1770        settings.save_project(tmp.path()).unwrap();
1771
1772        // Should still be TOML
1773        let content = fs::read_to_string(&toml_path).unwrap();
1774        assert!(content.contains("new-theme"));
1775        assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1776    }
1777
1778    #[test]
1779    fn test_save_project_creates_json_by_default() {
1780        let tmp = tempfile::tempdir().unwrap();
1781        let oxi_dir = tmp.path().join(".oxi");
1782        fs::create_dir_all(&oxi_dir).unwrap();
1783        // Don't create any settings file
1784
1785        let mut settings = Settings::default();
1786        settings.theme = "json-theme".to_string();
1787        settings.save_project(tmp.path()).unwrap();
1788
1789        // Should create JSON file
1790        let json_path = oxi_dir.join("settings.json");
1791        assert!(json_path.exists());
1792        let content = fs::read_to_string(&json_path).unwrap();
1793        assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1794        assert!(content.contains("json-theme"));
1795    }
1796
1797    // ── Custom provider tests ───────────────────────────────────────
1798
1799    #[test]
1800    fn test_custom_provider_default_api() {
1801        use super::CustomProvider;
1802        let cp = CustomProvider {
1803            name: "test".to_string(),
1804            base_url: "https://api.test.com/v1".to_string(),
1805            api_key_env: "TEST_API_KEY".to_string(),
1806            api: super::default_custom_provider_api(),
1807        };
1808        assert_eq!(cp.api, "openai-completions");
1809    }
1810
1811    #[test]
1812    fn test_custom_provider_toml_deserialize() {
1813        let toml_content = r#"
1814[[custom_providers]]
1815name = "minimax"
1816base_url = "https://api.minimax.chat/v1"
1817api_key_env = "MINIMAX_API_KEY"
1818api = "openai-completions"
1819
1820[[custom_providers]]
1821name = "zai"
1822base_url = "https://api.z.ai/v1"
1823api_key_env = "ZAI_API_KEY"
1824api = "openai-responses"
1825"#;
1826        let settings: Settings = toml::from_str(toml_content).unwrap();
1827        assert_eq!(settings.custom_providers.len(), 2);
1828        assert_eq!(settings.custom_providers[0].name, "minimax");
1829        assert_eq!(
1830            settings.custom_providers[0].base_url,
1831            "https://api.minimax.chat/v1"
1832        );
1833        assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
1834        assert_eq!(settings.custom_providers[0].api, "openai-completions");
1835        assert_eq!(settings.custom_providers[1].name, "zai");
1836        assert_eq!(settings.custom_providers[1].api, "openai-responses");
1837    }
1838
1839    #[test]
1840    fn test_custom_provider_json_deserialize() {
1841        let json_content = r#"{
1842            "custom_providers": [
1843                {
1844                    "name": "minimax",
1845                    "base_url": "https://api.minimax.chat/v1",
1846                    "api_key_env": "MINIMAX_API_KEY",
1847                    "api": "openai-completions"
1848                }
1849            ]
1850        }"#;
1851        let settings: Settings = serde_json::from_str(json_content).unwrap();
1852        assert_eq!(settings.custom_providers.len(), 1);
1853        assert_eq!(settings.custom_providers[0].name, "minimax");
1854    }
1855
1856    #[test]
1857    fn test_custom_provider_toml_roundtrip() {
1858        let mut settings = Settings::default();
1859        settings.custom_providers.push(super::CustomProvider {
1860            name: "test".to_string(),
1861            base_url: "https://api.test.com/v1".to_string(),
1862            api_key_env: "TEST_API_KEY".to_string(),
1863            api: "openai-completions".to_string(),
1864        });
1865
1866        let toml_str = toml::to_string_pretty(&settings).unwrap();
1867        let parsed: Settings = toml::from_str(&toml_str).unwrap();
1868        assert_eq!(parsed.custom_providers.len(), 1);
1869        assert_eq!(parsed.custom_providers[0].name, "test");
1870        assert_eq!(
1871            parsed.custom_providers[0].base_url,
1872            "https://api.test.com/v1"
1873        );
1874    }
1875
1876    #[test]
1877    fn test_custom_provider_defaults_empty() {
1878        let settings = Settings::default();
1879        assert!(settings.custom_providers.is_empty());
1880    }
1881
1882    #[test]
1883    fn test_custom_provider_layer_file() {
1884        let base = Settings::default();
1885
1886        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1887        let toml_content = r#"
1888[[custom_providers]]
1889name = "my-provider"
1890base_url = "https://api.my-provider.com/v1"
1891api_key_env = "MY_PROVIDER_API_KEY"
1892"#;
1893        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1894
1895        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1896        assert_eq!(merged.custom_providers.len(), 1);
1897        assert_eq!(merged.custom_providers[0].name, "my-provider");
1898        // Default api value
1899        assert_eq!(merged.custom_providers[0].api, "openai-completions");
1900    }
1901}