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
700            .map(String::from)
701            .or_else(|| {
702                // Reconstruct full model ID from separate fields.
703                // Handles both cases:
704                //   - last_used_model = "anthropic/claude-sonnet-4" (full ID, stored by save_last_used)
705                //   - last_used_model = "claude-sonnet-4" + last_used_provider = "anthropic" (split)
706                let model = self.last_used_model.as_ref()?;
707                if model.contains('/') {
708                    // Already a full model ID
709                    Some(model.clone())
710                } else if let Some(ref provider) = self.last_used_provider {
711                    // Reconstruct from separate fields
712                    Some(format!("{}/{}", provider, model))
713                } else {
714                    Some(model.clone())
715                }
716            })
717    }
718
719    /// Get the effective provider.
720    /// Returns None if no provider is configured.
721    pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
722        cli_provider
723            .map(String::from)
724            .or_else(|| self.last_used_provider.clone())
725    }
726
727    /// Get the effective temperature, preferring `default_temperature` (f64)
728    /// over `temperature` (f32), falling back to `None`.
729    pub fn effective_temperature(&self) -> Option<f64> {
730        self.default_temperature
731            .or(self.temperature.map(|t| t as f64))
732    }
733
734    /// Get the effective max tokens, preferring `max_response_tokens` (usize)
735    /// over `max_tokens` (u32), falling back to `None`.
736    pub fn effective_max_tokens(&self) -> Option<usize> {
737        self.max_response_tokens
738            .or(self.max_tokens.map(|t| t as usize))
739    }
740
741    /// Get the configured router profile name.
742    pub fn router_profile(&self) -> Option<&str> {
743        self.router_profile.as_deref()
744    }
745
746    // ── Theme persistence ─────────────────────────────────────────────
747
748    /// Save the last used model/provider and persist to disk.
749    ///
750    /// Splits the model_id on first `/` to store provider and model separately.
751    pub fn save_last_used(model_id: &str) {
752        if let Ok(mut settings) = Self::load() {
753            if let Some((provider, model)) = model_id.split_once('/') {
754                settings.last_used_provider = Some(provider.to_string());
755                settings.last_used_model = Some(model.to_string());
756            } else {
757                settings.last_used_model = Some(model_id.to_string());
758            }
759            let _ = settings.save();
760        }
761    }
762
763    /// Save the current theme to settings and persist to disk.
764    pub fn save_theme(&mut self, name: &str) -> Result<()> {
765        self.theme = name.to_string();
766        self.save()
767    }
768
769    /// Get the theme name from settings, returning a default if not set.
770    pub fn get_theme_name(&self) -> String {
771        if self.theme.is_empty() || self.theme == "default" {
772            "oxi_dark".to_string()
773        } else {
774            self.theme.clone()
775        }
776    }
777
778    // ── Migration ────────────────────────────────────────────────────
779
780    /// Migrate settings from an older format version to the current one.
781    ///
782    /// Currently handles:
783    /// - Version 0 → Version 2 (adds JSON support, version bump)
784    /// - Version 1 → Version 2 (adds JSON support)
785    fn migrate(settings: Settings) -> Result<Settings> {
786        let mut settings = settings;
787
788        match settings.version {
789            SETTINGS_VERSION => {
790                // Already current — nothing to do.
791            }
792            0 => {
793                // Version 0 = pre-versioning config.
794                // Add any defaults that were introduced in version 1.
795                if settings.tool_timeout_seconds == 0 {
796                    settings.tool_timeout_seconds = default_tool_timeout();
797                }
798                settings.version = SETTINGS_VERSION;
799
800                tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
801            }
802            1 | 2 => {
803                // Version 1/2 → 4: dynamic_models field added + model/provider split.
804                settings.version = SETTINGS_VERSION;
805                tracing::info!(
806                    "Migrated settings from version {} to {}",
807                    settings.version,
808                    SETTINGS_VERSION
809                );
810            }
811            3 => {
812                // Version 3 → 4: migrate default_model → last_used_model.
813                if let Some(model) = settings.default_model.take() {
814                    if let Some((provider, model_name)) = model.split_once('/') {
815                        settings.last_used_provider = Some(provider.to_string());
816                        settings.last_used_model = Some(model_name.to_string());
817                    } else {
818                        settings.last_used_model = Some(model);
819                    }
820                }
821                settings.version = SETTINGS_VERSION;
822                tracing::info!(
823                    "Migrated settings from version 3 to {} (default_model → last_used_model)",
824                    SETTINGS_VERSION
825                );
826            }
827            v if v > SETTINGS_VERSION => {
828                // Future version — we don't know how to downgrade.
829                anyhow::bail!(
830                    "Settings version {} is newer than supported version {}. \
831                     Please update oxi.",
832                    v,
833                    SETTINGS_VERSION
834                );
835            }
836            v => {
837                // Unknown old version — best-effort migration.
838                tracing::warn!(
839                    "Unknown settings version {}, attempting migration to {}",
840                    v,
841                    SETTINGS_VERSION
842                );
843                settings.version = SETTINGS_VERSION;
844            }
845        }
846
847        Ok(settings)
848    }
849}
850
851// ── Settings format detection ──────────────────────────────────────
852
853/// Supported settings file formats.
854#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
855pub enum SettingsFormat {
856    /// JSON format.
857    #[default]
858    Json,
859    /// TOML format.
860    Toml,
861}
862
863impl SettingsFormat {
864    /// Get the file extension for this format.
865    pub fn extension(&self) -> &'static str {
866        match self {
867            SettingsFormat::Json => "json",
868            SettingsFormat::Toml => "toml",
869        }
870    }
871}
872
873// ── JSON/TOML conversion helpers ────────────────────────────────────
874
875/// Convert a TOML Value to a serde_json::Value.
876fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
877    match toml {
878        toml::Value::String(s) => serde_json::Value::String(s),
879        toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
880        toml::Value::Float(f) => serde_json::Number::from_f64(f)
881            .map(serde_json::Value::Number)
882            .unwrap_or(serde_json::Value::Null),
883        toml::Value::Boolean(b) => serde_json::Value::Bool(b),
884        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
885        toml::Value::Array(arr) => {
886            serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
887        }
888        toml::Value::Table(table) => {
889            let obj = table
890                .into_iter()
891                .map(|(k, v)| (k, toml_value_to_json(v)))
892                .collect();
893            serde_json::Value::Object(obj)
894        }
895    }
896}
897
898/// Deep merge two JSON values. The second value overrides the first.
899fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
900    match (base, override_) {
901        // If either is not an object, the override wins
902        (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
903            let mut result = base_map;
904            for (key, override_value) in override_map {
905                let base_value = result.remove(&key);
906                let merged = match base_value {
907                    Some(base_v) => merge_json_values(base_v, override_value),
908                    None => override_value,
909                };
910                result.insert(key, merged);
911            }
912            serde_json::Value::Object(result)
913        }
914        // Override wins for non-objects
915        (_, override_) => override_,
916    }
917}
918
919/// Parse a thinking level from a string.
920pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
921    match s.to_lowercase().as_str() {
922        "off" | "none" => Some(ThinkingLevel::Off),
923        "minimal" => Some(ThinkingLevel::Minimal),
924        "low" => Some(ThinkingLevel::Low),
925        "medium" | "standard" => Some(ThinkingLevel::Medium),
926        "high" | "thorough" => Some(ThinkingLevel::High),
927        "xhigh" => Some(ThinkingLevel::XHigh),
928        _ => None,
929    }
930}
931
932/// Parse a boolean-like string (`"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"`).
933#[allow(dead_code)]
934fn parse_boolish(s: &str) -> Result<bool> {
935    match s.to_lowercase().as_str() {
936        "true" | "1" | "yes" | "on" => Ok(true),
937        "false" | "0" | "no" | "off" => Ok(false),
938        _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
939    }
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945    use std::io::Write as IoWrite;
946    use std::sync::Mutex;
947
948    /// Global lock to serialize all tests that manipulate process-wide env vars.
949    static ENV_LOCK: Mutex<()> = Mutex::new(());
950
951    /// RAII guard that removes listed env vars on creation and restores them on drop.
952    /// This prevents parallel test races where one test sets an env var that leaks into another.
953    struct EnvGuard {
954        saved: Vec<(String, Option<String>)>,
955    }
956
957    impl EnvGuard {
958        fn new(vars: &[&str]) -> Self {
959            let saved = vars
960                .iter()
961                .map(|&name| {
962                    let old = env::var(name).ok();
963                    env::remove_var(name);
964                    (name.to_string(), old)
965                })
966                .collect();
967            Self { saved }
968        }
969    }
970
971    impl Drop for EnvGuard {
972        fn drop(&mut self) {
973            for (name, old) in self.saved.drain(..) {
974                match old {
975                    Some(val) => env::set_var(&name, val),
976                    None => env::remove_var(&name),
977                }
978            }
979        }
980    }
981
982    // ── Struct tests ─────────────────────────────────────────────────
983
984    #[test]
985    fn test_default_settings() {
986        let settings = Settings::default();
987        assert_eq!(settings.version, SETTINGS_VERSION);
988        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
989        assert_eq!(settings.theme, "default");
990        assert!(settings.last_used_model.is_none());
991        assert!(settings.last_used_provider.is_none());
992        assert!(settings.extensions_enabled);
993        assert!(settings.auto_compaction);
994        assert_eq!(settings.tool_timeout_seconds, 120);
995        assert!(settings.stream_responses);
996    }
997
998    #[test]
999    fn test_merge_cli() {
1000        let mut settings = Settings::default();
1001        settings.last_used_model = Some("gpt-4o".to_string());
1002
1003        settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1004        assert_eq!(settings.last_used_model, Some("claude".to_string()));
1005
1006        settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1007        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1008
1009        // Test routing flags
1010        settings.merge_cli(
1011            None,
1012            None,
1013            Some(true),
1014            Some(false),
1015            Some(vec!["openai/gpt-4o".to_string()]),
1016            Some(false),
1017        );
1018        assert!(settings.enable_routing);
1019        assert!(!settings.prefer_cost_efficient);
1020        assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1021        assert!(!settings.disable_fallback);
1022
1023        // Test disable_fallback sets enable_fallback to false
1024        let mut settings2 = Settings::default();
1025        settings2.merge_cli(None, None, None, None, None, Some(true));
1026        assert!(settings2.disable_fallback);
1027        assert!(!settings2.enable_fallback);
1028    }
1029
1030    // ── Layered loading ──────────────────────────────────────────────
1031
1032    #[test]
1033    fn test_layer_file_overrides() {
1034        let base = Settings::default();
1035
1036        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1037        let toml_content = r#"
1038last_used_model = "openai/gpt-4o"
1039theme = "dracula"
1040"#;
1041        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1042
1043        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1044        assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1045        assert_eq!(merged.theme, "dracula");
1046        // Unchanged fields retain defaults
1047        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1048        assert!(merged.extensions_enabled);
1049    }
1050
1051    #[test]
1052    fn test_layer_file_preserves_unset() {
1053        let mut base = Settings::default();
1054        base.last_used_provider = Some("deepseek".to_string());
1055
1056        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1057        // Only override theme — provider should remain
1058        let toml_content = "theme = \"monokai\"\n";
1059        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1060
1061        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1062        assert_eq!(merged.theme, "monokai");
1063        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1064    }
1065
1066    #[test]
1067    fn test_load_from_dir_with_project_config() {
1068        let _guard = EnvGuard::new(&[
1069            "OXI_MODEL",
1070            "OXI_PROVIDER",
1071            "OXI_THEME",
1072            "OXI_TOOL_TIMEOUT",
1073            "OXI_TEMPERATURE",
1074            "OXI_MAX_TOKENS",
1075            "OXI_SESSION_DIR",
1076            "OXI_STREAM",
1077            "OXI_EXTENSIONS_ENABLED",
1078        ]);
1079        let tmp = tempfile::tempdir().unwrap();
1080        let oxi_dir = tmp.path().join(".oxi");
1081        fs::create_dir_all(&oxi_dir).unwrap();
1082        let settings_path = oxi_dir.join("settings.toml");
1083        // Write v3 format: default_model contains "provider/model"
1084        fs::write(
1085            &settings_path,
1086            "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1087        )
1088        .unwrap();
1089
1090        let settings = Settings::load_from(tmp.path()).unwrap();
1091        // Migration moves default_model → last_used_model
1092        assert_eq!(
1093            settings.last_used_model,
1094            Some("gemini-2.0-flash".to_string())
1095        );
1096        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1097    }
1098
1099    #[test]
1100    fn test_load_from_dir_no_config() {
1101        // Clean env vars that load_from() reads via apply_env()
1102        let _guard = EnvGuard::new(&[
1103            "OXI_MODEL",
1104            "OXI_PROVIDER",
1105            "OXI_THEME",
1106            "OXI_TOOL_TIMEOUT",
1107            "OXI_TEMPERATURE",
1108            "OXI_MAX_TOKENS",
1109            "OXI_SESSION_DIR",
1110            "OXI_STREAM",
1111            "OXI_EXTENSIONS_ENABLED",
1112        ]);
1113        let tmp = tempfile::tempdir().unwrap();
1114        let settings = Settings::load_from(tmp.path()).unwrap();
1115        // Falls back to defaults (may include global ~/.oxi/settings)
1116        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1117    }
1118
1119    // ── Environment variables ────────────────────────────────────────
1120
1121    #[test]
1122    fn test_from_env() {
1123        // NOTE: Environment variable overrides are disabled.
1124        // from_env() returns defaults only.
1125        let _guard = EnvGuard::new(&[
1126            // no env vars to clear
1127            "OXI_MODEL",
1128            "OXI_THEME",
1129            "OXI_TOOL_TIMEOUT",
1130            "OXI_PROVIDER",
1131            "OXI_DEFAULT_MODEL",
1132        ]);
1133
1134        let settings = Settings::from_env();
1135        // All fields should be at defaults since env overrides are disabled
1136        assert_eq!(settings.last_used_model, None);
1137        assert_eq!(settings.theme, "default");
1138        assert_eq!(settings.tool_timeout_seconds, 120);
1139    }
1140
1141    #[test]
1142    fn test_apply_env_boolish() {
1143        // NOTE: Environment variable overrides are disabled.
1144        // apply_env() is a no-op.
1145        let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1146        env::set_var("OXI_STREAM", "false");
1147        env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1148
1149        let mut settings = Settings::default();
1150        settings.apply_env();
1151        // Since env overrides are disabled, values stay at defaults
1152        assert!(settings.stream_responses); // default is true
1153        assert!(settings.extensions_enabled); // default is true
1154    }
1155
1156    #[test]
1157    fn test_apply_env_temperature() {
1158        // NOTE: Environment variable overrides are disabled.
1159        let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1160        env::set_var("OXI_TEMPERATURE", "0.7");
1161
1162        let mut settings = Settings::default();
1163        settings.apply_env();
1164        // Since env overrides are disabled, temperature stays at None
1165        assert_eq!(settings.default_temperature, None);
1166    }
1167
1168    #[test]
1169    fn test_env_does_not_override_when_unset() {
1170        let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1171        let settings = Settings::from_env();
1172        assert!(settings.last_used_model.is_none());
1173        assert!(settings.last_used_provider.is_none());
1174    }
1175
1176    #[test]
1177    fn test_parse_thinking_level() {
1178        assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1179        assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1180        assert_eq!(
1181            parse_thinking_level("MINIMAL"),
1182            Some(ThinkingLevel::Minimal)
1183        );
1184        assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1185        assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1186        assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1187        assert_eq!(
1188            parse_thinking_level("Standard"),
1189            Some(ThinkingLevel::Medium)
1190        );
1191        assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1192        assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1193        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1194        assert_eq!(parse_thinking_level("invalid"), None);
1195    }
1196
1197    #[test]
1198    fn test_parse_boolish() {
1199        assert!(parse_boolish("true").unwrap());
1200        assert!(parse_boolish("1").unwrap());
1201        assert!(parse_boolish("yes").unwrap());
1202        assert!(parse_boolish("ON").unwrap());
1203        assert!(!parse_boolish("false").unwrap());
1204        assert!(!parse_boolish("0").unwrap());
1205        assert!(!parse_boolish("no").unwrap());
1206        assert!(!parse_boolish("OFF").unwrap());
1207        assert!(parse_boolish("maybe").is_err());
1208    }
1209
1210    // ── Effective accessors ──────────────────────────────────────────
1211
1212    #[test]
1213    fn test_effective_model_returns_last_used() {
1214        let mut settings = Settings::default();
1215        settings.last_used_model = Some("openai/gpt-4o".to_string());
1216        assert_eq!(
1217            settings.effective_model(None),
1218            Some("openai/gpt-4o".to_string())
1219        );
1220    }
1221
1222    #[test]
1223    fn test_effective_model_cli_overrides() {
1224        let mut settings = Settings::default();
1225        settings.last_used_model = Some("openai/gpt-4o".to_string());
1226        assert_eq!(
1227            settings.effective_model(Some("anthropic/claude-3")),
1228            Some("anthropic/claude-3".to_string())
1229        );
1230    }
1231
1232    #[test]
1233    fn test_effective_model_none_when_unset() {
1234        let settings = Settings::default();
1235        assert_eq!(settings.effective_model(None), None);
1236    }
1237
1238    #[test]
1239    fn test_effective_model_falls_back_to_last_used() {
1240        let mut settings = Settings::default();
1241        settings.last_used_model = Some("anthropic/claude-3".to_string());
1242        assert_eq!(
1243            settings.effective_model(None),
1244            Some("anthropic/claude-3".to_string())
1245        );
1246    }
1247
1248    #[test]
1249    fn test_effective_model_returns_none_when_nothing_set() {
1250        let settings = Settings::default();
1251        assert_eq!(settings.effective_model(None), None);
1252    }
1253
1254    #[test]
1255    fn test_effective_temperature_prefers_f64() {
1256        let mut settings = Settings::default();
1257        settings.temperature = Some(0.5);
1258        settings.default_temperature = Some(0.7);
1259        assert_eq!(settings.effective_temperature(), Some(0.7));
1260    }
1261
1262    #[test]
1263    fn test_effective_temperature_falls_back_to_f32() {
1264        let mut settings = Settings::default();
1265        settings.temperature = Some(0.5);
1266        assert_eq!(settings.effective_temperature(), Some(0.5));
1267    }
1268
1269    #[test]
1270    fn test_effective_max_tokens_prefers_usize() {
1271        let mut settings = Settings::default();
1272        settings.max_tokens = Some(1024);
1273        settings.max_response_tokens = Some(4096);
1274        assert_eq!(settings.effective_max_tokens(), Some(4096));
1275    }
1276
1277    #[test]
1278    fn test_effective_max_tokens_falls_back_to_u32() {
1279        let mut settings = Settings::default();
1280        settings.max_tokens = Some(1024);
1281        assert_eq!(settings.effective_max_tokens(), Some(1024));
1282    }
1283
1284    // ── Session dir ──────────────────────────────────────────────────
1285
1286    #[test]
1287    fn test_effective_session_dir_default() {
1288        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1289        let settings = Settings::default();
1290        let dir = settings.effective_session_dir().unwrap();
1291        assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1292    }
1293
1294    #[test]
1295    fn test_effective_session_dir_from_field() {
1296        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1297        let mut settings = Settings::default();
1298        settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1299        assert_eq!(
1300            settings.effective_session_dir().unwrap(),
1301            PathBuf::from("/tmp/oxi-sessions")
1302        );
1303    }
1304
1305    #[test]
1306    fn test_effective_session_dir_env_disabled() {
1307        // NOTE: Environment variable overrides are disabled.
1308        // OXI_SESSION_DIR is ignored; effective_session_dir() returns the field value (or default).
1309        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1310        env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
1311        let settings = Settings::default();
1312        // Env is ignored, so it should use the default path, not /tmp/env-sessions
1313        let dir = settings.effective_session_dir().unwrap();
1314        assert!(
1315            dir.ends_with("sessions"),
1316            "expected default sessions dir, got: {:?}",
1317            dir
1318        );
1319    }
1320
1321    // ── Migration ────────────────────────────────────────────────────
1322
1323    #[test]
1324    fn test_migration_v0_to_v1() {
1325        let mut settings = Settings::default();
1326        settings.version = 0;
1327        settings.tool_timeout_seconds = 0; // v0 might not have this field
1328
1329        let migrated = Settings::migrate(settings).unwrap();
1330        assert_eq!(migrated.version, SETTINGS_VERSION);
1331        assert_eq!(migrated.tool_timeout_seconds, 120);
1332    }
1333
1334    #[test]
1335    fn test_migration_already_current() {
1336        let settings = Settings::default();
1337        let migrated = Settings::migrate(settings).unwrap();
1338        assert_eq!(migrated.version, SETTINGS_VERSION);
1339    }
1340
1341    #[test]
1342    fn test_migration_v3_to_v4_splits_model() {
1343        let mut settings = Settings::default();
1344        settings.version = 3;
1345        settings.default_model = Some("openai/gpt-4o".to_string());
1346        settings.default_provider = None;
1347
1348        let migrated = Settings::migrate(settings).unwrap();
1349        assert_eq!(migrated.version, SETTINGS_VERSION);
1350        assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1351        assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1352    }
1353
1354    #[test]
1355    fn test_migration_v3_no_slash_keeps_model() {
1356        let mut settings = Settings::default();
1357        settings.version = 3;
1358        settings.default_model = Some("bare-model-name".to_string());
1359
1360        let migrated = Settings::migrate(settings).unwrap();
1361        assert_eq!(migrated.version, SETTINGS_VERSION);
1362        assert_eq!(
1363            migrated.last_used_model,
1364            Some("bare-model-name".to_string())
1365        );
1366    }
1367
1368    #[test]
1369    fn test_migration_future_version_fails() {
1370        let mut settings = Settings::default();
1371        settings.version = 9999;
1372        assert!(Settings::migrate(settings).is_err());
1373    }
1374
1375    // ── Persistence ──────────────────────────────────────────────────
1376
1377    #[test]
1378    fn test_save_and_load_roundtrip() {
1379        let tmp = tempfile::tempdir().unwrap();
1380        let settings_path = tmp.path().join("settings.toml");
1381
1382        let mut original = Settings::default();
1383        original.last_used_model = Some("gpt-4o".to_string());
1384        original.last_used_provider = Some("openai".to_string());
1385        original.theme = "dracula".to_string();
1386        original.tool_timeout_seconds = 60;
1387
1388        // Serialize
1389        let content = toml::to_string_pretty(&original).unwrap();
1390        fs::write(&settings_path, &content).unwrap();
1391
1392        // Deserialize
1393        let loaded_content = fs::read_to_string(&settings_path).unwrap();
1394        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1395
1396        assert_eq!(loaded.last_used_model, original.last_used_model);
1397        assert_eq!(loaded.theme, original.theme);
1398        assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1399    }
1400
1401    #[test]
1402    fn test_toml_roundtrip_preserves_new_fields() {
1403        let mut settings = Settings::default();
1404        settings.default_temperature = Some(0.8);
1405        settings.max_response_tokens = Some(8192);
1406        settings.auto_compaction = false;
1407        settings.extensions_enabled = false;
1408        settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1409
1410        let toml_str = toml::to_string_pretty(&settings).unwrap();
1411        let parsed: Settings = toml::from_str(&toml_str).unwrap();
1412
1413        assert_eq!(parsed.default_temperature, Some(0.8));
1414        assert_eq!(parsed.max_response_tokens, Some(8192));
1415        assert!(!parsed.auto_compaction);
1416        assert!(!parsed.extensions_enabled);
1417        assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1418    }
1419
1420    // ── JSON format tests ──────────────────────────────────────────────
1421
1422    #[test]
1423    fn test_json_roundtrip() {
1424        let mut settings = Settings::default();
1425        settings.last_used_model = Some("gpt-4o".to_string());
1426        settings.last_used_provider = Some("openai".to_string());
1427        settings.theme = "dracula".to_string();
1428        settings.tool_timeout_seconds = 60;
1429        settings.default_temperature = Some(0.8);
1430        settings.max_response_tokens = Some(8192);
1431
1432        let json_str = serde_json::to_string_pretty(&settings).unwrap();
1433        let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1434
1435        assert_eq!(parsed.last_used_model, settings.last_used_model);
1436        assert_eq!(parsed.theme, settings.theme);
1437        assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1438        assert_eq!(parsed.default_temperature, settings.default_temperature);
1439        assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1440    }
1441
1442    #[test]
1443    fn test_json_serialize_for_format() {
1444        let mut settings = Settings::default();
1445        settings.last_used_model = Some("claude-3".to_string());
1446        settings.last_used_provider = Some("anthropic".to_string());
1447        settings.thinking_level = ThinkingLevel::Minimal;
1448
1449        let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1450        let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1451
1452        assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1453        assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1454    }
1455
1456    #[test]
1457    fn test_toml_serialize_for_format() {
1458        let mut settings = Settings::default();
1459        settings.last_used_model = Some("gemini-pro".to_string());
1460        settings.last_used_provider = Some("google".to_string());
1461        settings.thinking_level = ThinkingLevel::High;
1462
1463        let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1464        let parsed: Settings = toml::from_str(&toml_content).unwrap();
1465
1466        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1467        assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1468    }
1469
1470    #[test]
1471    fn test_parse_from_str_json() {
1472        let json_content = r#"{
1473            "last_used_model": "gpt-4",
1474            "last_used_provider": "openai",
1475            "theme": "nord",
1476            "tool_timeout_seconds": 90
1477        }"#;
1478
1479        let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1480        assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1481        assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1482        assert_eq!(settings.theme, "nord");
1483        assert_eq!(settings.tool_timeout_seconds, 90);
1484        // Unchanged fields retain defaults
1485        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1486        assert!(settings.extensions_enabled);
1487    }
1488
1489    #[test]
1490    fn test_parse_from_str_toml() {
1491        let toml_content = r#"
1492last_used_model = "claude-opus"
1493last_used_provider = "anthropic"
1494theme = "monokai"
1495tool_timeout_seconds = 45
1496"#;
1497
1498        let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1499        assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1500        assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1501        assert_eq!(settings.theme, "monokai");
1502        assert_eq!(settings.tool_timeout_seconds, 45);
1503        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1504    }
1505
1506    #[test]
1507    fn test_layer_file_json() {
1508        let base = Settings::default();
1509
1510        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1511        let json_content = r#"{
1512            "last_used_model": "gpt-4o",
1513            "last_used_provider": "openai",
1514            "theme": "dracula",
1515            "auto_compaction": false
1516        }"#;
1517        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1518
1519        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1520        assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1521        assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1522        assert_eq!(merged.theme, "dracula");
1523        assert!(!merged.auto_compaction);
1524        // Unchanged fields retain defaults
1525        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1526        assert!(merged.extensions_enabled);
1527        assert_eq!(merged.tool_timeout_seconds, 120);
1528    }
1529
1530    #[test]
1531    fn test_layer_file_json_preserves_unset() {
1532        let mut base = Settings::default();
1533        base.last_used_provider = Some("deepseek".to_string());
1534
1535        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1536        let json_content = r#"{ "theme": "nord" }"#;
1537        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1538
1539        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1540        assert_eq!(merged.theme, "nord");
1541        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1542    }
1543
1544    #[test]
1545    fn test_save_to_json() {
1546        let tmp = tempfile::tempdir().unwrap();
1547        let settings_path = tmp.path().join("settings.json");
1548
1549        let mut settings = Settings::default();
1550        settings.last_used_model = Some("gpt-4o".to_string());
1551        settings.last_used_provider = Some("openai".to_string());
1552        settings.theme = "dracula".to_string();
1553        settings.tool_timeout_seconds = 60;
1554
1555        settings.save_to(&settings_path).unwrap();
1556
1557        // Verify it's valid JSON
1558        let content = fs::read_to_string(&settings_path).unwrap();
1559        let parsed: Settings = serde_json::from_str(&content).unwrap();
1560        assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1561        assert_eq!(parsed.theme, "dracula");
1562        assert_eq!(parsed.tool_timeout_seconds, 60);
1563    }
1564
1565    #[test]
1566    fn test_save_to_toml() {
1567        let tmp = tempfile::tempdir().unwrap();
1568        let settings_path = tmp.path().join("settings.toml");
1569
1570        let mut settings = Settings::default();
1571        settings.last_used_model = Some("gemini-pro".to_string());
1572        settings.last_used_provider = Some("google".to_string());
1573        settings.theme = "monokai".to_string();
1574        settings.tool_timeout_seconds = 90;
1575
1576        settings.save_to(&settings_path).unwrap();
1577
1578        // Verify it's valid TOML
1579        let content = fs::read_to_string(&settings_path).unwrap();
1580        let parsed: Settings = toml::from_str(&content).unwrap();
1581        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1582        assert_eq!(parsed.theme, "monokai");
1583        assert_eq!(parsed.tool_timeout_seconds, 90);
1584    }
1585
1586    #[test]
1587    fn test_load_from_dir_with_json_project_config() {
1588        let _guard = EnvGuard::new(&[
1589            "OXI_MODEL",
1590            "OXI_PROVIDER",
1591            "OXI_THEME",
1592            "OXI_TOOL_TIMEOUT",
1593            "OXI_TEMPERATURE",
1594            "OXI_MAX_TOKENS",
1595            "OXI_SESSION_DIR",
1596            "OXI_STREAM",
1597            "OXI_EXTENSIONS_ENABLED",
1598        ]);
1599        let tmp = tempfile::tempdir().unwrap();
1600        let oxi_dir = tmp.path().join(".oxi");
1601        fs::create_dir_all(&oxi_dir).unwrap();
1602        let settings_path = oxi_dir.join("settings.json");
1603        // v3 format: default_model has provider/model
1604        let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1605        fs::write(&settings_path, json_content).unwrap();
1606
1607        let settings = Settings::load_from(tmp.path()).unwrap();
1608        // Migration splits provider from model
1609        assert_eq!(
1610            settings.last_used_model,
1611            Some("gemini-2.0-flash".to_string())
1612        );
1613        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1614    }
1615
1616    #[test]
1617    fn test_find_project_settings_json_priority() {
1618        let tmp = tempfile::tempdir().unwrap();
1619        let oxi_dir = tmp.path().join(".oxi");
1620        fs::create_dir_all(&oxi_dir).unwrap();
1621
1622        // Create both files
1623        let json_path = oxi_dir.join("settings.json");
1624        let toml_path = oxi_dir.join("settings.toml");
1625        fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1626        fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1627
1628        // JSON takes priority
1629        let found = Settings::find_project_settings(tmp.path());
1630        assert!(found.is_some());
1631        assert_eq!(
1632            found.unwrap().file_name().unwrap().to_str().unwrap(),
1633            "settings.json"
1634        );
1635    }
1636
1637    #[test]
1638    fn test_find_project_settings_json_only() {
1639        let tmp = tempfile::tempdir().unwrap();
1640        let oxi_dir = tmp.path().join(".oxi");
1641        fs::create_dir_all(&oxi_dir).unwrap();
1642
1643        let json_path = oxi_dir.join("settings.json");
1644        fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1645
1646        let found = Settings::find_project_settings(tmp.path());
1647        assert!(found.is_some());
1648        assert_eq!(
1649            found.unwrap().file_name().unwrap().to_str().unwrap(),
1650            "settings.json"
1651        );
1652    }
1653
1654    #[test]
1655    fn test_find_project_settings_toml_fallback() {
1656        let tmp = tempfile::tempdir().unwrap();
1657        let oxi_dir = tmp.path().join(".oxi");
1658        fs::create_dir_all(&oxi_dir).unwrap();
1659
1660        let toml_path = oxi_dir.join("settings.toml");
1661        fs::write(&toml_path, r#"theme = "test""#).unwrap();
1662
1663        let found = Settings::find_project_settings(tmp.path());
1664        assert!(found.is_some());
1665        assert_eq!(
1666            found.unwrap().file_name().unwrap().to_str().unwrap(),
1667            "settings.toml"
1668        );
1669    }
1670
1671    #[test]
1672    fn test_detect_format() {
1673        let json_path = PathBuf::from("/test/settings.json");
1674        let toml_path = PathBuf::from("/test/settings.toml");
1675        let unknown_path = PathBuf::from("/test/settings");
1676
1677        assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1678        assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1679        assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1680        // Default
1681    }
1682
1683    #[test]
1684    fn test_settings_format_extension() {
1685        assert_eq!(SettingsFormat::Json.extension(), "json");
1686        assert_eq!(SettingsFormat::Toml.extension(), "toml");
1687    }
1688
1689    #[test]
1690    fn test_layer_json_over_toml() {
1691        // Test that when loading, JSON takes priority over TOML
1692        let tmp = tempfile::tempdir().unwrap();
1693        let oxi_dir = tmp.path().join(".oxi");
1694        fs::create_dir_all(&oxi_dir).unwrap();
1695
1696        let json_path = oxi_dir.join("settings.json");
1697        let toml_path = oxi_dir.join("settings.toml");
1698
1699        // JSON has model set to "json-model"
1700        fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1701        // TOML has model set to "toml-model"
1702        fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1703
1704        // JSON takes priority
1705        let settings = Settings::load_from(tmp.path()).unwrap();
1706        assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1707    }
1708
1709    #[test]
1710    fn test_mixed_format_loading() {
1711        // Test loading a TOML file through the generic layer_file
1712        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1713        let toml_content = r#"
1714last_used_model = "loaded-via-toml"
1715theme = "loaded-theme"
1716stream_responses = false
1717"#;
1718        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1719
1720        let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1721        assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
1722        assert_eq!(merged.theme, "loaded-theme");
1723        assert!(!merged.stream_responses);
1724    }
1725
1726    #[test]
1727    fn test_merge_json_values() {
1728        let base = serde_json::json!({
1729            "version": 1,
1730            "theme": "default",
1731            "extensions": ["ext1"],
1732            "nested": {
1733                "a": 1,
1734                "b": 2
1735            }
1736        });
1737
1738        let override_ = serde_json::json!({
1739            "version": 2,
1740            "theme": "dark",
1741            "extensions": ["ext2"],
1742            "nested": {
1743                "b": 20,
1744                "c": 30
1745            }
1746        });
1747
1748        let merged = merge_json_values(base, override_);
1749
1750        assert_eq!(merged["version"], 2);
1751        assert_eq!(merged["theme"], "dark");
1752        // Arrays are replaced, not merged
1753        assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1754        // Nested objects are deeply merged
1755        assert_eq!(merged["nested"]["a"], 1);
1756        assert_eq!(merged["nested"]["b"], 20);
1757        assert_eq!(merged["nested"]["c"], 30);
1758    }
1759
1760    #[test]
1761    fn test_save_project_preserves_existing_format() {
1762        let tmp = tempfile::tempdir().unwrap();
1763        let oxi_dir = tmp.path().join(".oxi");
1764        fs::create_dir_all(&oxi_dir).unwrap();
1765
1766        // Create existing TOML file
1767        let toml_path = oxi_dir.join("settings.toml");
1768        fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1769
1770        let mut settings = Settings::default();
1771        settings.theme = "new-theme".to_string();
1772        settings.save_project(tmp.path()).unwrap();
1773
1774        // Should still be TOML
1775        let content = fs::read_to_string(&toml_path).unwrap();
1776        assert!(content.contains("new-theme"));
1777        assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1778    }
1779
1780    #[test]
1781    fn test_save_project_creates_json_by_default() {
1782        let tmp = tempfile::tempdir().unwrap();
1783        let oxi_dir = tmp.path().join(".oxi");
1784        fs::create_dir_all(&oxi_dir).unwrap();
1785        // Don't create any settings file
1786
1787        let mut settings = Settings::default();
1788        settings.theme = "json-theme".to_string();
1789        settings.save_project(tmp.path()).unwrap();
1790
1791        // Should create JSON file
1792        let json_path = oxi_dir.join("settings.json");
1793        assert!(json_path.exists());
1794        let content = fs::read_to_string(&json_path).unwrap();
1795        assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1796        assert!(content.contains("json-theme"));
1797    }
1798
1799    // ── Custom provider tests ───────────────────────────────────────
1800
1801    #[test]
1802    fn test_custom_provider_default_api() {
1803        use super::CustomProvider;
1804        let cp = CustomProvider {
1805            name: "test".to_string(),
1806            base_url: "https://api.test.com/v1".to_string(),
1807            api_key_env: "TEST_API_KEY".to_string(),
1808            api: super::default_custom_provider_api(),
1809        };
1810        assert_eq!(cp.api, "openai-completions");
1811    }
1812
1813    #[test]
1814    fn test_custom_provider_toml_deserialize() {
1815        let toml_content = r#"
1816[[custom_providers]]
1817name = "minimax"
1818base_url = "https://api.minimax.chat/v1"
1819api_key_env = "MINIMAX_API_KEY"
1820api = "openai-completions"
1821
1822[[custom_providers]]
1823name = "zai"
1824base_url = "https://api.z.ai/v1"
1825api_key_env = "ZAI_API_KEY"
1826api = "openai-responses"
1827"#;
1828        let settings: Settings = toml::from_str(toml_content).unwrap();
1829        assert_eq!(settings.custom_providers.len(), 2);
1830        assert_eq!(settings.custom_providers[0].name, "minimax");
1831        assert_eq!(
1832            settings.custom_providers[0].base_url,
1833            "https://api.minimax.chat/v1"
1834        );
1835        assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
1836        assert_eq!(settings.custom_providers[0].api, "openai-completions");
1837        assert_eq!(settings.custom_providers[1].name, "zai");
1838        assert_eq!(settings.custom_providers[1].api, "openai-responses");
1839    }
1840
1841    #[test]
1842    fn test_custom_provider_json_deserialize() {
1843        let json_content = r#"{
1844            "custom_providers": [
1845                {
1846                    "name": "minimax",
1847                    "base_url": "https://api.minimax.chat/v1",
1848                    "api_key_env": "MINIMAX_API_KEY",
1849                    "api": "openai-completions"
1850                }
1851            ]
1852        }"#;
1853        let settings: Settings = serde_json::from_str(json_content).unwrap();
1854        assert_eq!(settings.custom_providers.len(), 1);
1855        assert_eq!(settings.custom_providers[0].name, "minimax");
1856    }
1857
1858    #[test]
1859    fn test_custom_provider_toml_roundtrip() {
1860        let mut settings = Settings::default();
1861        settings.custom_providers.push(super::CustomProvider {
1862            name: "test".to_string(),
1863            base_url: "https://api.test.com/v1".to_string(),
1864            api_key_env: "TEST_API_KEY".to_string(),
1865            api: "openai-completions".to_string(),
1866        });
1867
1868        let toml_str = toml::to_string_pretty(&settings).unwrap();
1869        let parsed: Settings = toml::from_str(&toml_str).unwrap();
1870        assert_eq!(parsed.custom_providers.len(), 1);
1871        assert_eq!(parsed.custom_providers[0].name, "test");
1872        assert_eq!(
1873            parsed.custom_providers[0].base_url,
1874            "https://api.test.com/v1"
1875        );
1876    }
1877
1878    #[test]
1879    fn test_custom_provider_defaults_empty() {
1880        let settings = Settings::default();
1881        assert!(settings.custom_providers.is_empty());
1882    }
1883
1884    #[test]
1885    fn test_custom_provider_layer_file() {
1886        let base = Settings::default();
1887
1888        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1889        let toml_content = r#"
1890[[custom_providers]]
1891name = "my-provider"
1892base_url = "https://api.my-provider.com/v1"
1893api_key_env = "MY_PROVIDER_API_KEY"
1894"#;
1895        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1896
1897        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1898        assert_eq!(merged.custom_providers.len(), 1);
1899        assert_eq!(merged.custom_providers[0].name, "my-provider");
1900        // Default api value
1901        assert_eq!(merged.custom_providers[0].api, "openai-completions");
1902    }
1903}