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!(settings.last_used_model, Some("gemini-2.0-flash".to_string()));
1073        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1074    }
1075
1076    #[test]
1077    fn test_load_from_dir_no_config() {
1078        // Clean env vars that load_from() reads via apply_env()
1079        let _guard = EnvGuard::new(&[
1080            "OXI_MODEL",
1081            "OXI_PROVIDER",
1082            "OXI_THEME",
1083            "OXI_TOOL_TIMEOUT",
1084            "OXI_TEMPERATURE",
1085            "OXI_MAX_TOKENS",
1086            "OXI_SESSION_DIR",
1087            "OXI_STREAM",
1088            "OXI_EXTENSIONS_ENABLED",
1089        ]);
1090        let tmp = tempfile::tempdir().unwrap();
1091        let settings = Settings::load_from(tmp.path()).unwrap();
1092        // Falls back to defaults (may include global ~/.oxi/settings)
1093        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1094    }
1095
1096    // ── Environment variables ────────────────────────────────────────
1097
1098    #[test]
1099    fn test_from_env() {
1100        // NOTE: Environment variable overrides are disabled.
1101        // from_env() returns defaults only.
1102        let _guard = EnvGuard::new(&[
1103            // no env vars to clear
1104            "OXI_MODEL",
1105            "OXI_THEME",
1106            "OXI_TOOL_TIMEOUT",
1107            "OXI_PROVIDER",
1108            "OXI_DEFAULT_MODEL",
1109        ]);
1110
1111        let settings = Settings::from_env();
1112        // All fields should be at defaults since env overrides are disabled
1113        assert_eq!(settings.last_used_model, None);
1114        assert_eq!(settings.theme, "default");
1115        assert_eq!(settings.tool_timeout_seconds, 120);
1116    }
1117
1118    #[test]
1119    fn test_apply_env_boolish() {
1120        // NOTE: Environment variable overrides are disabled.
1121        // apply_env() is a no-op.
1122        let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1123        env::set_var("OXI_STREAM", "false");
1124        env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1125
1126        let mut settings = Settings::default();
1127        settings.apply_env();
1128        // Since env overrides are disabled, values stay at defaults
1129        assert!(settings.stream_responses); // default is true
1130        assert!(settings.extensions_enabled); // default is true
1131    }
1132
1133    #[test]
1134    fn test_apply_env_temperature() {
1135        // NOTE: Environment variable overrides are disabled.
1136        let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1137        env::set_var("OXI_TEMPERATURE", "0.7");
1138
1139        let mut settings = Settings::default();
1140        settings.apply_env();
1141        // Since env overrides are disabled, temperature stays at None
1142        assert_eq!(settings.default_temperature, None);
1143    }
1144
1145    #[test]
1146    fn test_env_does_not_override_when_unset() {
1147        let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1148        let settings = Settings::from_env();
1149        assert!(settings.last_used_model.is_none());
1150        assert!(settings.last_used_provider.is_none());
1151    }
1152
1153    #[test]
1154    fn test_parse_thinking_level() {
1155        assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1156        assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1157        assert_eq!(
1158            parse_thinking_level("MINIMAL"),
1159            Some(ThinkingLevel::Minimal)
1160        );
1161        assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1162        assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1163        assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1164        assert_eq!(
1165            parse_thinking_level("Standard"),
1166            Some(ThinkingLevel::Medium)
1167        );
1168        assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1169        assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1170        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1171        assert_eq!(parse_thinking_level("invalid"), None);
1172    }
1173
1174    #[test]
1175    fn test_parse_boolish() {
1176        assert!(parse_boolish("true").unwrap());
1177        assert!(parse_boolish("1").unwrap());
1178        assert!(parse_boolish("yes").unwrap());
1179        assert!(parse_boolish("ON").unwrap());
1180        assert!(!parse_boolish("false").unwrap());
1181        assert!(!parse_boolish("0").unwrap());
1182        assert!(!parse_boolish("no").unwrap());
1183        assert!(!parse_boolish("OFF").unwrap());
1184        assert!(parse_boolish("maybe").is_err());
1185    }
1186
1187    // ── Effective accessors ──────────────────────────────────────────
1188
1189    #[test]
1190    fn test_effective_model_returns_last_used() {
1191        let mut settings = Settings::default();
1192        settings.last_used_model = Some("openai/gpt-4o".to_string());
1193        assert_eq!(
1194            settings.effective_model(None),
1195            Some("openai/gpt-4o".to_string())
1196        );
1197    }
1198
1199    #[test]
1200    fn test_effective_model_cli_overrides() {
1201        let mut settings = Settings::default();
1202        settings.last_used_model = Some("openai/gpt-4o".to_string());
1203        assert_eq!(
1204            settings.effective_model(Some("anthropic/claude-3")),
1205            Some("anthropic/claude-3".to_string())
1206        );
1207    }
1208
1209    #[test]
1210    fn test_effective_model_none_when_unset() {
1211        let settings = Settings::default();
1212        assert_eq!(settings.effective_model(None), None);
1213    }
1214
1215    #[test]
1216    fn test_effective_model_falls_back_to_last_used() {
1217        let mut settings = Settings::default();
1218        settings.last_used_model = Some("anthropic/claude-3".to_string());
1219        assert_eq!(
1220            settings.effective_model(None),
1221            Some("anthropic/claude-3".to_string())
1222        );
1223    }
1224
1225    #[test]
1226    fn test_effective_model_returns_none_when_nothing_set() {
1227        let settings = Settings::default();
1228        assert_eq!(settings.effective_model(None), None);
1229    }
1230
1231    #[test]
1232    fn test_effective_temperature_prefers_f64() {
1233        let mut settings = Settings::default();
1234        settings.temperature = Some(0.5);
1235        settings.default_temperature = Some(0.7);
1236        assert_eq!(settings.effective_temperature(), Some(0.7));
1237    }
1238
1239    #[test]
1240    fn test_effective_temperature_falls_back_to_f32() {
1241        let mut settings = Settings::default();
1242        settings.temperature = Some(0.5);
1243        assert_eq!(settings.effective_temperature(), Some(0.5));
1244    }
1245
1246    #[test]
1247    fn test_effective_max_tokens_prefers_usize() {
1248        let mut settings = Settings::default();
1249        settings.max_tokens = Some(1024);
1250        settings.max_response_tokens = Some(4096);
1251        assert_eq!(settings.effective_max_tokens(), Some(4096));
1252    }
1253
1254    #[test]
1255    fn test_effective_max_tokens_falls_back_to_u32() {
1256        let mut settings = Settings::default();
1257        settings.max_tokens = Some(1024);
1258        assert_eq!(settings.effective_max_tokens(), Some(1024));
1259    }
1260
1261    // ── Session dir ──────────────────────────────────────────────────
1262
1263    #[test]
1264    fn test_effective_session_dir_default() {
1265        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1266        let settings = Settings::default();
1267        let dir = settings.effective_session_dir().unwrap();
1268        assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1269    }
1270
1271    #[test]
1272    fn test_effective_session_dir_from_field() {
1273        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1274        let mut settings = Settings::default();
1275        settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1276        assert_eq!(
1277            settings.effective_session_dir().unwrap(),
1278            PathBuf::from("/tmp/oxi-sessions")
1279        );
1280    }
1281
1282    #[test]
1283    fn test_effective_session_dir_env_disabled() {
1284        // NOTE: Environment variable overrides are disabled.
1285        // OXI_SESSION_DIR is ignored; effective_session_dir() returns the field value (or default).
1286        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1287        env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
1288        let settings = Settings::default();
1289        // Env is ignored, so it should use the default path, not /tmp/env-sessions
1290        let dir = settings.effective_session_dir().unwrap();
1291        assert!(
1292            dir.ends_with("sessions"),
1293            "expected default sessions dir, got: {:?}",
1294            dir
1295        );
1296    }
1297
1298    // ── Migration ────────────────────────────────────────────────────
1299
1300    #[test]
1301    fn test_migration_v0_to_v1() {
1302        let mut settings = Settings::default();
1303        settings.version = 0;
1304        settings.tool_timeout_seconds = 0; // v0 might not have this field
1305
1306        let migrated = Settings::migrate(settings).unwrap();
1307        assert_eq!(migrated.version, SETTINGS_VERSION);
1308        assert_eq!(migrated.tool_timeout_seconds, 120);
1309    }
1310
1311    #[test]
1312    fn test_migration_already_current() {
1313        let settings = Settings::default();
1314        let migrated = Settings::migrate(settings).unwrap();
1315        assert_eq!(migrated.version, SETTINGS_VERSION);
1316    }
1317
1318    #[test]
1319    fn test_migration_v3_to_v4_splits_model() {
1320        let mut settings = Settings::default();
1321        settings.version = 3;
1322        settings.default_model = Some("openai/gpt-4o".to_string());
1323        settings.default_provider = None;
1324
1325        let migrated = Settings::migrate(settings).unwrap();
1326        assert_eq!(migrated.version, SETTINGS_VERSION);
1327        assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1328        assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1329    }
1330
1331    #[test]
1332    fn test_migration_v3_no_slash_keeps_model() {
1333        let mut settings = Settings::default();
1334        settings.version = 3;
1335        settings.default_model = Some("bare-model-name".to_string());
1336
1337        let migrated = Settings::migrate(settings).unwrap();
1338        assert_eq!(migrated.version, SETTINGS_VERSION);
1339        assert_eq!(migrated.last_used_model, Some("bare-model-name".to_string()));
1340    }
1341
1342    #[test]
1343    fn test_migration_future_version_fails() {
1344        let mut settings = Settings::default();
1345        settings.version = 9999;
1346        assert!(Settings::migrate(settings).is_err());
1347    }
1348
1349    // ── Persistence ──────────────────────────────────────────────────
1350
1351    #[test]
1352    fn test_save_and_load_roundtrip() {
1353        let tmp = tempfile::tempdir().unwrap();
1354        let settings_path = tmp.path().join("settings.toml");
1355
1356        let mut original = Settings::default();
1357        original.last_used_model = Some("gpt-4o".to_string());
1358        original.last_used_provider = Some("openai".to_string());
1359        original.theme = "dracula".to_string();
1360        original.tool_timeout_seconds = 60;
1361
1362        // Serialize
1363        let content = toml::to_string_pretty(&original).unwrap();
1364        fs::write(&settings_path, &content).unwrap();
1365
1366        // Deserialize
1367        let loaded_content = fs::read_to_string(&settings_path).unwrap();
1368        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1369
1370        assert_eq!(loaded.last_used_model, original.last_used_model);
1371        assert_eq!(loaded.theme, original.theme);
1372        assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1373    }
1374
1375    #[test]
1376    fn test_toml_roundtrip_preserves_new_fields() {
1377        let mut settings = Settings::default();
1378        settings.default_temperature = Some(0.8);
1379        settings.max_response_tokens = Some(8192);
1380        settings.auto_compaction = false;
1381        settings.extensions_enabled = false;
1382        settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1383
1384        let toml_str = toml::to_string_pretty(&settings).unwrap();
1385        let parsed: Settings = toml::from_str(&toml_str).unwrap();
1386
1387        assert_eq!(parsed.default_temperature, Some(0.8));
1388        assert_eq!(parsed.max_response_tokens, Some(8192));
1389        assert!(!parsed.auto_compaction);
1390        assert!(!parsed.extensions_enabled);
1391        assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1392    }
1393
1394    // ── JSON format tests ──────────────────────────────────────────────
1395
1396    #[test]
1397    fn test_json_roundtrip() {
1398        let mut settings = Settings::default();
1399        settings.last_used_model = Some("gpt-4o".to_string());
1400        settings.last_used_provider = Some("openai".to_string());
1401        settings.theme = "dracula".to_string();
1402        settings.tool_timeout_seconds = 60;
1403        settings.default_temperature = Some(0.8);
1404        settings.max_response_tokens = Some(8192);
1405
1406        let json_str = serde_json::to_string_pretty(&settings).unwrap();
1407        let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1408
1409        assert_eq!(parsed.last_used_model, settings.last_used_model);
1410        assert_eq!(parsed.theme, settings.theme);
1411        assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1412        assert_eq!(parsed.default_temperature, settings.default_temperature);
1413        assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1414    }
1415
1416    #[test]
1417    fn test_json_serialize_for_format() {
1418        let mut settings = Settings::default();
1419        settings.last_used_model = Some("claude-3".to_string());
1420        settings.last_used_provider = Some("anthropic".to_string());
1421        settings.thinking_level = ThinkingLevel::Minimal;
1422
1423        let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1424        let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1425
1426        assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1427        assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1428    }
1429
1430    #[test]
1431    fn test_toml_serialize_for_format() {
1432        let mut settings = Settings::default();
1433        settings.last_used_model = Some("gemini-pro".to_string());
1434        settings.last_used_provider = Some("google".to_string());
1435        settings.thinking_level = ThinkingLevel::High;
1436
1437        let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1438        let parsed: Settings = toml::from_str(&toml_content).unwrap();
1439
1440        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1441        assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1442    }
1443
1444    #[test]
1445    fn test_parse_from_str_json() {
1446        let json_content = r#"{
1447            "last_used_model": "gpt-4",
1448            "last_used_provider": "openai",
1449            "theme": "nord",
1450            "tool_timeout_seconds": 90
1451        }"#;
1452
1453        let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1454        assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1455        assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1456        assert_eq!(settings.theme, "nord");
1457        assert_eq!(settings.tool_timeout_seconds, 90);
1458        // Unchanged fields retain defaults
1459        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1460        assert!(settings.extensions_enabled);
1461    }
1462
1463    #[test]
1464    fn test_parse_from_str_toml() {
1465        let toml_content = r#"
1466last_used_model = "claude-opus"
1467last_used_provider = "anthropic"
1468theme = "monokai"
1469tool_timeout_seconds = 45
1470"#;
1471
1472        let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1473        assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1474        assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1475        assert_eq!(settings.theme, "monokai");
1476        assert_eq!(settings.tool_timeout_seconds, 45);
1477        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1478    }
1479
1480    #[test]
1481    fn test_layer_file_json() {
1482        let base = Settings::default();
1483
1484        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1485        let json_content = r#"{
1486            "last_used_model": "gpt-4o",
1487            "last_used_provider": "openai",
1488            "theme": "dracula",
1489            "auto_compaction": false
1490        }"#;
1491        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1492
1493        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1494        assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1495        assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1496        assert_eq!(merged.theme, "dracula");
1497        assert!(!merged.auto_compaction);
1498        // Unchanged fields retain defaults
1499        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1500        assert!(merged.extensions_enabled);
1501        assert_eq!(merged.tool_timeout_seconds, 120);
1502    }
1503
1504    #[test]
1505    fn test_layer_file_json_preserves_unset() {
1506        let mut base = Settings::default();
1507        base.last_used_provider = Some("deepseek".to_string());
1508
1509        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1510        let json_content = r#"{ "theme": "nord" }"#;
1511        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1512
1513        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1514        assert_eq!(merged.theme, "nord");
1515        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1516    }
1517
1518    #[test]
1519    fn test_save_to_json() {
1520        let tmp = tempfile::tempdir().unwrap();
1521        let settings_path = tmp.path().join("settings.json");
1522
1523        let mut settings = Settings::default();
1524        settings.last_used_model = Some("gpt-4o".to_string());
1525        settings.last_used_provider = Some("openai".to_string());
1526        settings.theme = "dracula".to_string();
1527        settings.tool_timeout_seconds = 60;
1528
1529        settings.save_to(&settings_path).unwrap();
1530
1531        // Verify it's valid JSON
1532        let content = fs::read_to_string(&settings_path).unwrap();
1533        let parsed: Settings = serde_json::from_str(&content).unwrap();
1534        assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1535        assert_eq!(parsed.theme, "dracula");
1536        assert_eq!(parsed.tool_timeout_seconds, 60);
1537    }
1538
1539    #[test]
1540    fn test_save_to_toml() {
1541        let tmp = tempfile::tempdir().unwrap();
1542        let settings_path = tmp.path().join("settings.toml");
1543
1544        let mut settings = Settings::default();
1545        settings.last_used_model = Some("gemini-pro".to_string());
1546        settings.last_used_provider = Some("google".to_string());
1547        settings.theme = "monokai".to_string();
1548        settings.tool_timeout_seconds = 90;
1549
1550        settings.save_to(&settings_path).unwrap();
1551
1552        // Verify it's valid TOML
1553        let content = fs::read_to_string(&settings_path).unwrap();
1554        let parsed: Settings = toml::from_str(&content).unwrap();
1555        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1556        assert_eq!(parsed.theme, "monokai");
1557        assert_eq!(parsed.tool_timeout_seconds, 90);
1558    }
1559
1560    #[test]
1561    fn test_load_from_dir_with_json_project_config() {
1562        let _guard = EnvGuard::new(&[
1563            "OXI_MODEL",
1564            "OXI_PROVIDER",
1565            "OXI_THEME",
1566            "OXI_TOOL_TIMEOUT",
1567            "OXI_TEMPERATURE",
1568            "OXI_MAX_TOKENS",
1569            "OXI_SESSION_DIR",
1570            "OXI_STREAM",
1571            "OXI_EXTENSIONS_ENABLED",
1572        ]);
1573        let tmp = tempfile::tempdir().unwrap();
1574        let oxi_dir = tmp.path().join(".oxi");
1575        fs::create_dir_all(&oxi_dir).unwrap();
1576        let settings_path = oxi_dir.join("settings.json");
1577        // v3 format: default_model has provider/model
1578        let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1579        fs::write(&settings_path, json_content).unwrap();
1580
1581        let settings = Settings::load_from(tmp.path()).unwrap();
1582        // Migration splits provider from model
1583        assert_eq!(settings.last_used_model, Some("gemini-2.0-flash".to_string()));
1584        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1585    }
1586
1587    #[test]
1588    fn test_find_project_settings_json_priority() {
1589        let tmp = tempfile::tempdir().unwrap();
1590        let oxi_dir = tmp.path().join(".oxi");
1591        fs::create_dir_all(&oxi_dir).unwrap();
1592
1593        // Create both files
1594        let json_path = oxi_dir.join("settings.json");
1595        let toml_path = oxi_dir.join("settings.toml");
1596        fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1597        fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1598
1599        // JSON takes priority
1600        let found = Settings::find_project_settings(tmp.path());
1601        assert!(found.is_some());
1602        assert_eq!(
1603            found.unwrap().file_name().unwrap().to_str().unwrap(),
1604            "settings.json"
1605        );
1606    }
1607
1608    #[test]
1609    fn test_find_project_settings_json_only() {
1610        let tmp = tempfile::tempdir().unwrap();
1611        let oxi_dir = tmp.path().join(".oxi");
1612        fs::create_dir_all(&oxi_dir).unwrap();
1613
1614        let json_path = oxi_dir.join("settings.json");
1615        fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1616
1617        let found = Settings::find_project_settings(tmp.path());
1618        assert!(found.is_some());
1619        assert_eq!(
1620            found.unwrap().file_name().unwrap().to_str().unwrap(),
1621            "settings.json"
1622        );
1623    }
1624
1625    #[test]
1626    fn test_find_project_settings_toml_fallback() {
1627        let tmp = tempfile::tempdir().unwrap();
1628        let oxi_dir = tmp.path().join(".oxi");
1629        fs::create_dir_all(&oxi_dir).unwrap();
1630
1631        let toml_path = oxi_dir.join("settings.toml");
1632        fs::write(&toml_path, r#"theme = "test""#).unwrap();
1633
1634        let found = Settings::find_project_settings(tmp.path());
1635        assert!(found.is_some());
1636        assert_eq!(
1637            found.unwrap().file_name().unwrap().to_str().unwrap(),
1638            "settings.toml"
1639        );
1640    }
1641
1642    #[test]
1643    fn test_detect_format() {
1644        let json_path = PathBuf::from("/test/settings.json");
1645        let toml_path = PathBuf::from("/test/settings.toml");
1646        let unknown_path = PathBuf::from("/test/settings");
1647
1648        assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1649        assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1650        assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1651        // Default
1652    }
1653
1654    #[test]
1655    fn test_settings_format_extension() {
1656        assert_eq!(SettingsFormat::Json.extension(), "json");
1657        assert_eq!(SettingsFormat::Toml.extension(), "toml");
1658    }
1659
1660    #[test]
1661    fn test_layer_json_over_toml() {
1662        // Test that when loading, JSON takes priority over TOML
1663        let tmp = tempfile::tempdir().unwrap();
1664        let oxi_dir = tmp.path().join(".oxi");
1665        fs::create_dir_all(&oxi_dir).unwrap();
1666
1667        let json_path = oxi_dir.join("settings.json");
1668        let toml_path = oxi_dir.join("settings.toml");
1669
1670        // JSON has model set to "json-model"
1671        fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1672        // TOML has model set to "toml-model"
1673        fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1674
1675        // JSON takes priority
1676        let settings = Settings::load_from(tmp.path()).unwrap();
1677        assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1678    }
1679
1680    #[test]
1681    fn test_mixed_format_loading() {
1682        // Test loading a TOML file through the generic layer_file
1683        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1684        let toml_content = r#"
1685last_used_model = "loaded-via-toml"
1686theme = "loaded-theme"
1687stream_responses = false
1688"#;
1689        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1690
1691        let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1692        assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
1693        assert_eq!(merged.theme, "loaded-theme");
1694        assert!(!merged.stream_responses);
1695    }
1696
1697    #[test]
1698    fn test_merge_json_values() {
1699        let base = serde_json::json!({
1700            "version": 1,
1701            "theme": "default",
1702            "extensions": ["ext1"],
1703            "nested": {
1704                "a": 1,
1705                "b": 2
1706            }
1707        });
1708
1709        let override_ = serde_json::json!({
1710            "version": 2,
1711            "theme": "dark",
1712            "extensions": ["ext2"],
1713            "nested": {
1714                "b": 20,
1715                "c": 30
1716            }
1717        });
1718
1719        let merged = merge_json_values(base, override_);
1720
1721        assert_eq!(merged["version"], 2);
1722        assert_eq!(merged["theme"], "dark");
1723        // Arrays are replaced, not merged
1724        assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1725        // Nested objects are deeply merged
1726        assert_eq!(merged["nested"]["a"], 1);
1727        assert_eq!(merged["nested"]["b"], 20);
1728        assert_eq!(merged["nested"]["c"], 30);
1729    }
1730
1731    #[test]
1732    fn test_save_project_preserves_existing_format() {
1733        let tmp = tempfile::tempdir().unwrap();
1734        let oxi_dir = tmp.path().join(".oxi");
1735        fs::create_dir_all(&oxi_dir).unwrap();
1736
1737        // Create existing TOML file
1738        let toml_path = oxi_dir.join("settings.toml");
1739        fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1740
1741        let mut settings = Settings::default();
1742        settings.theme = "new-theme".to_string();
1743        settings.save_project(tmp.path()).unwrap();
1744
1745        // Should still be TOML
1746        let content = fs::read_to_string(&toml_path).unwrap();
1747        assert!(content.contains("new-theme"));
1748        assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1749    }
1750
1751    #[test]
1752    fn test_save_project_creates_json_by_default() {
1753        let tmp = tempfile::tempdir().unwrap();
1754        let oxi_dir = tmp.path().join(".oxi");
1755        fs::create_dir_all(&oxi_dir).unwrap();
1756        // Don't create any settings file
1757
1758        let mut settings = Settings::default();
1759        settings.theme = "json-theme".to_string();
1760        settings.save_project(tmp.path()).unwrap();
1761
1762        // Should create JSON file
1763        let json_path = oxi_dir.join("settings.json");
1764        assert!(json_path.exists());
1765        let content = fs::read_to_string(&json_path).unwrap();
1766        assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1767        assert!(content.contains("json-theme"));
1768    }
1769
1770    // ── Custom provider tests ───────────────────────────────────────
1771
1772    #[test]
1773    fn test_custom_provider_default_api() {
1774        use super::CustomProvider;
1775        let cp = CustomProvider {
1776            name: "test".to_string(),
1777            base_url: "https://api.test.com/v1".to_string(),
1778            api_key_env: "TEST_API_KEY".to_string(),
1779            api: super::default_custom_provider_api(),
1780        };
1781        assert_eq!(cp.api, "openai-completions");
1782    }
1783
1784    #[test]
1785    fn test_custom_provider_toml_deserialize() {
1786        let toml_content = r#"
1787[[custom_providers]]
1788name = "minimax"
1789base_url = "https://api.minimax.chat/v1"
1790api_key_env = "MINIMAX_API_KEY"
1791api = "openai-completions"
1792
1793[[custom_providers]]
1794name = "zai"
1795base_url = "https://api.z.ai/v1"
1796api_key_env = "ZAI_API_KEY"
1797api = "openai-responses"
1798"#;
1799        let settings: Settings = toml::from_str(toml_content).unwrap();
1800        assert_eq!(settings.custom_providers.len(), 2);
1801        assert_eq!(settings.custom_providers[0].name, "minimax");
1802        assert_eq!(
1803            settings.custom_providers[0].base_url,
1804            "https://api.minimax.chat/v1"
1805        );
1806        assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
1807        assert_eq!(settings.custom_providers[0].api, "openai-completions");
1808        assert_eq!(settings.custom_providers[1].name, "zai");
1809        assert_eq!(settings.custom_providers[1].api, "openai-responses");
1810    }
1811
1812    #[test]
1813    fn test_custom_provider_json_deserialize() {
1814        let json_content = r#"{
1815            "custom_providers": [
1816                {
1817                    "name": "minimax",
1818                    "base_url": "https://api.minimax.chat/v1",
1819                    "api_key_env": "MINIMAX_API_KEY",
1820                    "api": "openai-completions"
1821                }
1822            ]
1823        }"#;
1824        let settings: Settings = serde_json::from_str(json_content).unwrap();
1825        assert_eq!(settings.custom_providers.len(), 1);
1826        assert_eq!(settings.custom_providers[0].name, "minimax");
1827    }
1828
1829    #[test]
1830    fn test_custom_provider_toml_roundtrip() {
1831        let mut settings = Settings::default();
1832        settings.custom_providers.push(super::CustomProvider {
1833            name: "test".to_string(),
1834            base_url: "https://api.test.com/v1".to_string(),
1835            api_key_env: "TEST_API_KEY".to_string(),
1836            api: "openai-completions".to_string(),
1837        });
1838
1839        let toml_str = toml::to_string_pretty(&settings).unwrap();
1840        let parsed: Settings = toml::from_str(&toml_str).unwrap();
1841        assert_eq!(parsed.custom_providers.len(), 1);
1842        assert_eq!(parsed.custom_providers[0].name, "test");
1843        assert_eq!(
1844            parsed.custom_providers[0].base_url,
1845            "https://api.test.com/v1"
1846        );
1847    }
1848
1849    #[test]
1850    fn test_custom_provider_defaults_empty() {
1851        let settings = Settings::default();
1852        assert!(settings.custom_providers.is_empty());
1853    }
1854
1855    #[test]
1856    fn test_custom_provider_layer_file() {
1857        let base = Settings::default();
1858
1859        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1860        let toml_content = r#"
1861[[custom_providers]]
1862name = "my-provider"
1863base_url = "https://api.my-provider.com/v1"
1864api_key_env = "MY_PROVIDER_API_KEY"
1865"#;
1866        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1867
1868        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1869        assert_eq!(merged.custom_providers.len(), 1);
1870        assert_eq!(merged.custom_providers[0].name, "my-provider");
1871        // Default api value
1872        assert_eq!(merged.custom_providers[0].api, "openai-completions");
1873    }
1874}