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