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    /// Default model name without provider prefix (e.g., "claude-sonnet-4-20250514")
85    pub default_model: Option<String>,
86
87    /// Default provider to use (e.g., "anthropic", "openai")
88    pub default_provider: Option<String>,
89
90    /// Last used model (automatically updated when user selects a model)
91    #[serde(default)]
92    pub last_used_model: Option<String>,
93
94    /// Last used provider (automatically updated when user selects a model)
95    #[serde(default)]
96    pub last_used_provider: Option<String>,
97
98    /// Max tokens for responses
99    pub max_tokens: Option<u32>,
100
101    /// Temperature for generation (0.0–2.0)
102    pub temperature: Option<f32>,
103
104    /// Default temperature as f64 (higher precision, takes precedence over `temperature`)
105    pub default_temperature: Option<f64>,
106
107    /// Maximum tokens for generation (usize variant, takes precedence over `max_tokens`)
108    pub max_response_tokens: Option<usize>,
109
110    // ── Session settings ─────────────────────────────────────────────
111    /// Session history size (entries to keep in memory)
112    #[serde(default = "default_session_history_size")]
113    pub session_history_size: usize,
114
115    /// Directory for storing sessions (default: `~/.oxi/sessions`)
116    pub session_dir: Option<PathBuf>,
117
118    // ── Behaviour flags ──────────────────────────────────────────────
119    /// Whether to stream responses
120    #[serde(default = "default_true")]
121    pub stream_responses: bool,
122
123    /// Whether extensions are enabled
124    #[serde(default = "default_true")]
125    pub extensions_enabled: bool,
126
127    /// Whether to auto-compact conversations that exceed context window
128    #[serde(default = "default_true")]
129    pub auto_compaction: bool,
130
131    /// Built-in tools to disable (by name, e.g. `["web_search", "github_search"]`).
132    /// All tools are enabled by default; list tools here to turn them off.
133    #[serde(default)]
134    pub disabled_tools: Vec<String>,
135
136    // ── Timeouts ─────────────────────────────────────────────────────
137    /// Timeout in seconds for tool execution
138    #[serde(default = "default_tool_timeout")]
139    pub tool_timeout_seconds: u64,
140
141    // ── Resource lists (managed by `oxi config`) ────────────────────
142    /// List of extension paths or npm package sources to load
143    #[serde(default)]
144    pub extensions: Vec<String>,
145
146    /// List of skill paths or npm package sources to load
147    #[serde(default)]
148    pub skills: Vec<String>,
149
150    /// List of prompt template paths to load
151    #[serde(default)]
152    pub prompts: Vec<String>,
153
154    /// List of theme paths to load
155    #[serde(default)]
156    pub themes: Vec<String>,
157
158    // ── Custom OpenAI-compatible providers ──────────────────────────────
159    /// Registered custom providers (loaded from `[[custom_provider]]` TOML sections).
160    #[serde(default)]
161    pub custom_providers: Vec<CustomProvider>,
162
163    // ── Dynamic model cache ─────────────────────────────────────────────
164    /// Cached model lists fetched from provider `/models` endpoints.
165    /// Key is the provider name, value is a list of model IDs.
166    /// Updated when API keys are entered in setup wizard or on demand.
167    #[serde(default)]
168    pub dynamic_models: HashMap<String, Vec<String>>,
169
170    // ── Multi-provider routing ─────────────────────────────────────────
171    /// Enable automatic complexity-based routing
172    #[serde(default = "default_false")]
173    pub enable_routing: bool,
174
175    /// Router profile name to use (e.g., "auto", "balanced").
176    #[serde(default)]
177    pub router_profile: Option<String>,
178
179    /// Prefer cost-efficient models when routing
180    #[serde(default = "default_true")]
181    pub prefer_cost_efficient: bool,
182
183    /// Fallback chain: ordered list of model IDs to try on failure
184    #[serde(default)]
185    pub fallback_chain: Vec<String>,
186
187    /// Whether to use provider fallback on errors (false = fail fast)
188    #[serde(default = "default_true")]
189    pub enable_fallback: bool,
190
191    /// Disable automatic fallback (same as enable_fallback = false)
192    #[serde(default)]
193    pub disable_fallback: bool,
194
195    /// Circuit breaker failure threshold per provider
196    #[serde(default = "default_circuit_failure_threshold")]
197    pub circuit_breaker_failure_threshold: u32,
198
199    /// Circuit breaker open duration in seconds
200    #[serde(default = "default_circuit_open_duration_secs")]
201    pub circuit_breaker_open_duration_secs: u64,
202}
203
204fn default_theme() -> String {
205    "default".to_string()
206}
207
208fn default_thinking_level() -> ThinkingLevel {
209    ThinkingLevel::Medium
210}
211
212fn default_session_history_size() -> usize {
213    100
214}
215
216fn default_true() -> bool {
217    true
218}
219
220fn default_false() -> bool {
221    false
222}
223
224fn default_circuit_failure_threshold() -> u32 {
225    5
226}
227
228fn default_circuit_open_duration_secs() -> u64 {
229    30
230}
231
232fn default_tool_timeout() -> u64 {
233    120
234}
235
236impl Default for Settings {
237    fn default() -> Self {
238        Self {
239            version: SETTINGS_VERSION,
240            thinking_level: ThinkingLevel::Medium,
241            theme: default_theme(),
242            default_model: None,
243            default_provider: None,
244            last_used_model: None,
245            last_used_provider: None,
246            max_tokens: None,
247            temperature: None,
248            default_temperature: None,
249            max_response_tokens: None,
250            session_history_size: default_session_history_size(),
251            session_dir: None,
252            stream_responses: true,
253            extensions_enabled: true,
254            auto_compaction: true,
255            disabled_tools: Vec::new(),
256            tool_timeout_seconds: default_tool_timeout(),
257            extensions: Vec::new(),
258            skills: Vec::new(),
259            prompts: Vec::new(),
260            themes: Vec::new(),
261            custom_providers: Vec::new(),
262            dynamic_models: HashMap::new(),
263            // Multi-provider routing defaults
264            enable_routing: false,
265            router_profile: None,
266            prefer_cost_efficient: true,
267            fallback_chain: Vec::new(),
268            enable_fallback: true,
269            disable_fallback: false,
270            circuit_breaker_failure_threshold: 5,
271            circuit_breaker_open_duration_secs: 30,
272        }
273    }
274}
275
276impl Settings {
277    // ── Paths ────────────────────────────────────────────────────────
278
279    /// Get the global settings directory path (`~/.oxi`).
280    pub fn settings_dir() -> Result<PathBuf> {
281        let base = dirs::home_dir().context("Cannot determine home directory")?;
282        Ok(base.join(".oxi"))
283    }
284
285    /// Get the global settings TOML file path (`~/.oxi/settings.toml`).
286    pub fn settings_toml_path() -> Result<PathBuf> {
287        Ok(Self::settings_dir()?.join("settings.toml"))
288    }
289
290    /// Get the global settings JSON file path (`~/.oxi/settings.json`).
291    pub fn settings_json_path() -> Result<PathBuf> {
292        Ok(Self::settings_dir()?.join("settings.json"))
293    }
294
295    /// Get the global settings file path (JSON takes priority).
296    ///
297    /// Returns the path to the settings file that should be used.
298    /// If both JSON and TOML exist, JSON is returned (takes priority).
299    /// If only one exists, that path is returned.
300    /// If neither exists, returns the JSON path by default.
301    pub fn settings_path() -> Result<PathBuf> {
302        let json_path = Self::settings_json_path()?;
303        let toml_path = Self::settings_toml_path()?;
304
305        if json_path.exists() && toml_path.exists() {
306            // Both exist: JSON takes priority
307            tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
308            return Ok(json_path);
309        }
310
311        if json_path.exists() {
312            return Ok(json_path);
313        }
314
315        if toml_path.exists() {
316            return Ok(toml_path);
317        }
318
319        // Neither exists: default to JSON
320        Ok(json_path)
321    }
322
323    /// Get the effective settings file path, preferring the specified format.
324    ///
325    /// If `prefer_json` is true, checks JSON first; otherwise checks TOML first.
326    /// Returns the first existing file, or the preferred path if neither exists.
327    pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
328        let json_path = Self::settings_json_path()?;
329        let toml_path = Self::settings_toml_path()?;
330
331        let (primary, secondary) = if prefer_json {
332            (&json_path, &toml_path)
333        } else {
334            (&toml_path, &json_path)
335        };
336
337        if primary.exists() {
338            return Ok(primary.clone());
339        }
340
341        if secondary.exists() {
342            return Ok(secondary.clone());
343        }
344
345        // Neither exists: return preferred path
346        Ok(primary.clone())
347    }
348
349    /// Detect the settings file format from its path.
350    pub fn detect_format(path: &Path) -> SettingsFormat {
351        match path.extension().and_then(|e| e.to_str()) {
352            Some("json") => SettingsFormat::Json,
353            Some("toml") => SettingsFormat::Toml,
354            _ => SettingsFormat::Json, // Default to JSON for unknown extensions
355        }
356    }
357
358    /// Get the project-local settings file path.
359    ///
360    /// Searches for `.oxi/settings.json` first, then `.oxi/settings.toml`.
361    /// Returns the first one found, or None if neither exists.
362    pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
363        let mut dir = start_dir.to_path_buf();
364        loop {
365            // Check JSON first (priority), then TOML
366            let json_candidate = dir.join(".oxi").join("settings.json");
367            if json_candidate.exists() {
368                return Some(json_candidate);
369            }
370
371            let toml_candidate = dir.join(".oxi").join("settings.toml");
372            if toml_candidate.exists() {
373                return Some(toml_candidate);
374            }
375
376            if !dir.pop() {
377                return None;
378            }
379        }
380    }
381
382    /// Resolve the effective session directory.
383    ///
384    /// Priority: `session_dir` field → `~/.oxi/sessions`.
385    pub fn effective_session_dir(&self) -> Result<PathBuf> {
386        if let Some(ref dir) = self.session_dir {
387            return Ok(dir.clone());
388        }
389        Ok(Self::settings_dir()?.join("sessions"))
390    }
391
392    // ── Loading ──────────────────────────────────────────────────────
393
394    /// Load settings, applying all layers:
395    ///
396    /// 1. Built-in defaults
397    /// 2. Global `~/.oxi/settings.toml`
398    /// 3. Project `.oxi/settings.toml`
399    /// 4. Environment variable overrides
400    ///
401    /// # Examples
402    ///
403    /// ```ignore
404    /// use oxi_cli::Settings;
405    ///
406    /// let settings = Settings::load().expect("Failed to load settings");
407    /// println!("Using model: {}", settings.effective_model(None));
408    /// ```
409    pub fn load() -> Result<Self> {
410        Self::load_from_cwd()
411    }
412
413    /// Load settings with an explicit working directory for project config discovery.
414    pub fn load_from(dir: &std::path::Path) -> Result<Self> {
415        // 1. Start from defaults
416        let mut settings = Settings::default();
417
418        // 2. Layer global config
419        if let Ok(global_path) = Self::settings_path() {
420            if global_path.exists() {
421                settings = Self::layer_file(&settings, &global_path)?;
422            }
423        }
424
425        // 3. Layer project config
426        if let Some(project_path) = Self::find_project_settings(dir) {
427            settings = Self::layer_file(&settings, &project_path)?;
428        }
429
430        // 4. Layer environment variables
431        settings.apply_env();
432
433        // 5. Run migration if needed
434        settings = Self::migrate(settings)?;
435
436        Ok(settings)
437    }
438
439    /// Convenience: load from current working directory.
440    pub fn load_from_cwd() -> Result<Self> {
441        let cwd = env::current_dir().context("Cannot determine current directory")?;
442        Self::load_from(&cwd)
443    }
444
445    /// Parse a settings file (TOML or JSON) and overlay its values onto `base`.
446    ///
447    /// The format is auto-detected based on the file extension.
448    /// Fields present in the file replace those in `base`; absent fields
449    /// are left untouched.
450    fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
451        let content = fs::read_to_string(path)
452            .with_context(|| format!("Failed to read settings from {}", path.display()))?;
453
454        let format = Self::detect_format(path);
455        let overlay: serde_json::Value = match format {
456            SettingsFormat::Toml => {
457                let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
458                    format!("Failed to parse TOML settings from {}", path.display())
459                })?;
460                // Convert TOML to JSON Value for uniform merging
461                toml_value_to_json(toml_value)
462            }
463            SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
464                format!("Failed to parse JSON settings from {}", path.display())
465            })?,
466        };
467
468        // Re-serialize the base to JSON, merge with the overlay, then
469        // deserialize back. This gives correct "only override what's
470        // present" semantics.
471        let base_json =
472            serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
473
474        let merged = merge_json_values(base_json, overlay);
475        let result: Settings =
476            serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
477
478        Ok(result)
479    }
480
481    // ── Environment variables ────────────────────────────────────────
482
483    /// Apply environment variable overrides in-place.
484    ///
485    /// DEPRECATED: Environment variable overrides are being phased out in favor
486    /// of file-based configuration (`~/.oxi/settings.toml`). This method is
487    /// kept for CI/CD compatibility but should not be relied upon for local
488    /// development. Use `oxi config set` or `oxi setup` instead.
489    ///
490    /// Supported variables (CI/CD only):
491    ///
492    /// | Env var                    | Setting                |
493    /// |---------------------------|------------------------|
494    /// | `OXI_MODEL`               | `default_model`        |
495    /// | `OXI_PROVIDER`            | `default_provider`     |
496    /// | `OXI_THINKING`            | `thinking_level`       |
497    /// | `OXI_THEME`               | `theme`                |
498    /// | `OXI_MAX_TOKENS`          | `max_tokens`           |
499    /// | `OXI_TEMPERATURE`         | `default_temperature`  |
500    /// | `OXI_SESSION_DIR`         | `session_dir`          |
501    /// | `OXI_STREAM`              | `stream_responses`     |
502    /// | `OXI_EXTENSIONS_ENABLED`  | `extensions_enabled`   |
503    /// | `OXI_AUTO_COMPACTION`     | `auto_compaction`      |
504    /// | `OXI_TOOL_TIMEOUT`        | `tool_timeout_seconds` |
505    /// | `OXI_DISABLED_TOOLS`      | `disabled_tools`       |
506    #[allow(dead_code)]
507    pub fn apply_env(&mut self) {
508        // No-op: environment variable overrides are disabled.
509        // All configuration should come from settings.toml / settings.json.
510        // This method is kept for backward compatibility but does nothing.
511    }
512
513    /// Build a `Settings` instance from **only** environment variables
514    /// (all other fields stay at defaults).
515    ///
516    /// DEPRECATED: Returns defaults since env overrides are disabled.
517    /// Use `Settings::load()` to load from settings.toml instead.
518    #[allow(dead_code)]
519    pub fn from_env() -> Self {
520        Self::default()
521    }
522
523    // ── Persistence ──────────────────────────────────────────────────
524
525    /// Save settings to the global config file.
526    ///
527    /// Uses the format of the existing file if present, otherwise saves as JSON.
528    /// Preserves backward compatibility with existing TOML files.
529    pub fn save(&self) -> Result<()> {
530        let dir = Self::settings_dir()?;
531        let path = Self::settings_path()?;
532
533        if !dir.exists() {
534            fs::create_dir_all(&dir).with_context(|| {
535                format!("Failed to create settings directory {}", dir.display())
536            })?;
537        }
538
539        let format = Self::detect_format(&path);
540        let content = Self::serialize_for_format(self, format)?;
541
542        // Atomic write: write to temp file first, then rename
543        let tmp_path = path.with_extension("tmp");
544        fs::write(&tmp_path, &content)
545            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
546        fs::rename(&tmp_path, &path)
547            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
548
549        Ok(())
550    }
551
552    /// Save settings to a specific path, using the format determined by the file extension.
553    pub fn save_to(&self, path: &Path) -> Result<()> {
554        if let Some(parent) = path.parent() {
555            if !parent.exists() {
556                fs::create_dir_all(parent)
557                    .with_context(|| format!("Failed to create directory {}", parent.display()))?;
558            }
559        }
560
561        let format = Self::detect_format(path);
562        let content = Self::serialize_for_format(self, format)?;
563
564        // Atomic write
565        let tmp_path = path.with_extension("tmp");
566        fs::write(&tmp_path, &content)
567            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
568        fs::rename(&tmp_path, path)
569            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
570
571        Ok(())
572    }
573
574    /// Save settings to the project-local config file.
575    ///
576    /// Uses the format of the existing file if present, otherwise saves as JSON.
577    pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
578        let dir = project_dir.join(".oxi");
579
580        if !dir.exists() {
581            fs::create_dir_all(&dir).with_context(|| {
582                format!(
583                    "Failed to create project settings directory {}",
584                    dir.display()
585                )
586            })?;
587        }
588
589        // Check if a settings file already exists in project
590        let json_path = dir.join("settings.json");
591        let toml_path = dir.join("settings.toml");
592
593        let path = if json_path.exists() {
594            &json_path
595        } else if toml_path.exists() {
596            &toml_path
597        } else {
598            // Default to JSON for new files
599            &json_path
600        };
601
602        let format = Self::detect_format(path);
603        let content = Self::serialize_for_format(self, format)?;
604
605        // Atomic write
606        let tmp_path = path.with_extension("tmp");
607        fs::write(&tmp_path, &content)
608            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
609        fs::rename(&tmp_path, path)
610            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
611
612        Ok(())
613    }
614
615    /// Serialize settings to a string in the specified format.
616    pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
617        match format {
618            SettingsFormat::Toml => {
619                toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
620            }
621            SettingsFormat::Json => serde_json::to_string_pretty(settings)
622                .context("Failed to serialize settings to JSON"),
623        }
624    }
625
626    /// Parse settings from a string in the specified format.
627    pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
628        match format {
629            SettingsFormat::Toml => {
630                toml::from_str(content).context("Failed to parse TOML settings")
631            }
632            SettingsFormat::Json => {
633                serde_json::from_str(content).context("Failed to parse JSON settings")
634            }
635        }
636    }
637
638    // ── CLI overrides ────────────────────────────────────────────────
639
640    /// Merge with CLI arguments (CLI takes precedence).
641    ///
642    /// # Arguments
643    ///
644    /// * `model` — CLI-specified model override
645    /// * `provider` — CLI-specified provider override
646    /// * `enable_routing` — CLI-specified enable_routing override
647    /// * `prefer_cost_efficient` — CLI-specified prefer_cost_efficient override
648    /// * `fallback_chain` — CLI-specified fallback chain override
649    /// * `disable_fallback` — CLI-specified disable_fallback override
650    pub fn merge_cli(
651        &mut self,
652        model: Option<String>,
653        provider: Option<String>,
654        enable_routing: Option<bool>,
655        prefer_cost_efficient: Option<bool>,
656        fallback_chain: Option<Vec<String>>,
657        disable_fallback: Option<bool>,
658    ) {
659        if let Some(m) = model {
660            self.default_model = Some(m);
661        }
662        if let Some(p) = provider {
663            self.default_provider = Some(p);
664        }
665        if let Some(r) = enable_routing {
666            self.enable_routing = r;
667        }
668        if let Some(p) = prefer_cost_efficient {
669            self.prefer_cost_efficient = p;
670        }
671        if let Some(fc) = fallback_chain {
672            if !fc.is_empty() {
673                self.fallback_chain = fc;
674            }
675        }
676        if let Some(df) = disable_fallback {
677            self.disable_fallback = df;
678            // If disable_fallback is true, disable fallback
679            if df {
680                self.enable_fallback = false;
681            }
682        }
683    }
684
685    /// Get the effective model ID (provider/model format).
686    /// Combines `default_provider` + `default_model` when both are set.
687    /// Returns None if no model is configured.
688    pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
689        cli_model
690            .map(String::from)
691            .or_else(|| {
692                // Combine provider + model when both are present
693                if let (Some(provider), Some(model)) = (&self.default_provider, &self.default_model)
694                {
695                    Some(format!("{}/{}", provider, model))
696                } else {
697                    self.default_model.clone()
698                }
699            })
700            .or_else(|| self.last_used_model.clone())
701    }
702
703    /// Get the effective provider.
704    /// Returns None if no provider is configured.
705    pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
706        cli_provider
707            .map(String::from)
708            .or_else(|| self.default_provider.clone())
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: split default_model "provider/model" into separate fields.
793                if let Some(model) = settings.default_model.take() {
794                    if let Some((provider, model_name)) = model.split_once('/') {
795                        settings.default_provider = Some(provider.to_string());
796                        settings.default_model = Some(model_name.to_string());
797                    } else {
798                        // No slash — keep as-is (bare model name)
799                        settings.default_model = Some(model);
800                    }
801                }
802                settings.version = SETTINGS_VERSION;
803                tracing::info!(
804                    "Migrated settings from version 3 to {} (split default_model into provider + model)",
805                    SETTINGS_VERSION
806                );
807            }
808            v if v > SETTINGS_VERSION => {
809                // Future version — we don't know how to downgrade.
810                anyhow::bail!(
811                    "Settings version {} is newer than supported version {}. \
812                     Please update oxi.",
813                    v,
814                    SETTINGS_VERSION
815                );
816            }
817            v => {
818                // Unknown old version — best-effort migration.
819                tracing::warn!(
820                    "Unknown settings version {}, attempting migration to {}",
821                    v,
822                    SETTINGS_VERSION
823                );
824                settings.version = SETTINGS_VERSION;
825            }
826        }
827
828        Ok(settings)
829    }
830}
831
832// ── Settings format detection ──────────────────────────────────────
833
834/// Supported settings file formats.
835#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
836pub enum SettingsFormat {
837    /// JSON format.
838    #[default]
839    Json,
840    /// TOML format.
841    Toml,
842}
843
844impl SettingsFormat {
845    /// Get the file extension for this format.
846    pub fn extension(&self) -> &'static str {
847        match self {
848            SettingsFormat::Json => "json",
849            SettingsFormat::Toml => "toml",
850        }
851    }
852}
853
854// ── JSON/TOML conversion helpers ────────────────────────────────────
855
856/// Convert a TOML Value to a serde_json::Value.
857fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
858    match toml {
859        toml::Value::String(s) => serde_json::Value::String(s),
860        toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
861        toml::Value::Float(f) => serde_json::Number::from_f64(f)
862            .map(serde_json::Value::Number)
863            .unwrap_or(serde_json::Value::Null),
864        toml::Value::Boolean(b) => serde_json::Value::Bool(b),
865        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
866        toml::Value::Array(arr) => {
867            serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
868        }
869        toml::Value::Table(table) => {
870            let obj = table
871                .into_iter()
872                .map(|(k, v)| (k, toml_value_to_json(v)))
873                .collect();
874            serde_json::Value::Object(obj)
875        }
876    }
877}
878
879/// Deep merge two JSON values. The second value overrides the first.
880fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
881    match (base, override_) {
882        // If either is not an object, the override wins
883        (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
884            let mut result = base_map;
885            for (key, override_value) in override_map {
886                let base_value = result.remove(&key);
887                let merged = match base_value {
888                    Some(base_v) => merge_json_values(base_v, override_value),
889                    None => override_value,
890                };
891                result.insert(key, merged);
892            }
893            serde_json::Value::Object(result)
894        }
895        // Override wins for non-objects
896        (_, override_) => override_,
897    }
898}
899
900/// Parse a thinking level from a string.
901pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
902    match s.to_lowercase().as_str() {
903        "off" | "none" => Some(ThinkingLevel::Off),
904        "minimal" => Some(ThinkingLevel::Minimal),
905        "low" => Some(ThinkingLevel::Low),
906        "medium" | "standard" => Some(ThinkingLevel::Medium),
907        "high" | "thorough" => Some(ThinkingLevel::High),
908        "xhigh" => Some(ThinkingLevel::XHigh),
909        _ => None,
910    }
911}
912
913/// Parse a boolean-like string (`"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"`).
914#[allow(dead_code)]
915fn parse_boolish(s: &str) -> Result<bool> {
916    match s.to_lowercase().as_str() {
917        "true" | "1" | "yes" | "on" => Ok(true),
918        "false" | "0" | "no" | "off" => Ok(false),
919        _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use std::io::Write as IoWrite;
927    use std::sync::Mutex;
928
929    /// Global lock to serialize all tests that manipulate process-wide env vars.
930    static ENV_LOCK: Mutex<()> = Mutex::new(());
931
932    /// RAII guard that removes listed env vars on creation and restores them on drop.
933    /// This prevents parallel test races where one test sets an env var that leaks into another.
934    struct EnvGuard {
935        saved: Vec<(String, Option<String>)>,
936    }
937
938    impl EnvGuard {
939        fn new(vars: &[&str]) -> Self {
940            let saved = vars
941                .iter()
942                .map(|&name| {
943                    let old = env::var(name).ok();
944                    env::remove_var(name);
945                    (name.to_string(), old)
946                })
947                .collect();
948            Self { saved }
949        }
950    }
951
952    impl Drop for EnvGuard {
953        fn drop(&mut self) {
954            for (name, old) in self.saved.drain(..) {
955                match old {
956                    Some(val) => env::set_var(&name, val),
957                    None => env::remove_var(&name),
958                }
959            }
960        }
961    }
962
963    // ── Struct tests ─────────────────────────────────────────────────
964
965    #[test]
966    fn test_default_settings() {
967        let settings = Settings::default();
968        assert_eq!(settings.version, SETTINGS_VERSION);
969        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
970        assert_eq!(settings.theme, "default");
971        assert!(settings.default_model.is_none());
972        assert!(settings.default_provider.is_none());
973        assert!(settings.extensions_enabled);
974        assert!(settings.auto_compaction);
975        assert_eq!(settings.tool_timeout_seconds, 120);
976        assert!(settings.stream_responses);
977    }
978
979    #[test]
980    fn test_merge_cli() {
981        let mut settings = Settings::default();
982        settings.default_model = Some("gpt-4o".to_string());
983
984        settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
985        assert_eq!(settings.default_model, Some("claude".to_string()));
986
987        settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
988        assert_eq!(settings.default_provider, Some("google".to_string()));
989
990        // Test routing flags
991        settings.merge_cli(
992            None,
993            None,
994            Some(true),
995            Some(false),
996            Some(vec!["openai/gpt-4o".to_string()]),
997            Some(false),
998        );
999        assert!(settings.enable_routing);
1000        assert!(!settings.prefer_cost_efficient);
1001        assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1002        assert!(!settings.disable_fallback);
1003
1004        // Test disable_fallback sets enable_fallback to false
1005        let mut settings2 = Settings::default();
1006        settings2.merge_cli(None, None, None, None, None, Some(true));
1007        assert!(settings2.disable_fallback);
1008        assert!(!settings2.enable_fallback);
1009    }
1010
1011    // ── Layered loading ──────────────────────────────────────────────
1012
1013    #[test]
1014    fn test_layer_file_overrides() {
1015        let base = Settings::default();
1016
1017        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1018        let toml_content = r#"
1019default_model = "openai/gpt-4o"
1020theme = "dracula"
1021"#;
1022        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1023
1024        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1025        assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
1026        assert_eq!(merged.theme, "dracula");
1027        // Unchanged fields retain defaults
1028        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1029        assert!(merged.extensions_enabled);
1030    }
1031
1032    #[test]
1033    fn test_layer_file_preserves_unset() {
1034        let mut base = Settings::default();
1035        base.default_provider = Some("deepseek".to_string());
1036
1037        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1038        // Only override theme — provider should remain
1039        let toml_content = "theme = \"monokai\"\n";
1040        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1041
1042        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1043        assert_eq!(merged.theme, "monokai");
1044        assert_eq!(merged.default_provider, Some("deepseek".to_string()));
1045    }
1046
1047    #[test]
1048    fn test_load_from_dir_with_project_config() {
1049        let _guard = EnvGuard::new(&[
1050            "OXI_MODEL",
1051            "OXI_PROVIDER",
1052            "OXI_THEME",
1053            "OXI_TOOL_TIMEOUT",
1054            "OXI_TEMPERATURE",
1055            "OXI_MAX_TOKENS",
1056            "OXI_SESSION_DIR",
1057            "OXI_STREAM",
1058            "OXI_EXTENSIONS_ENABLED",
1059        ]);
1060        let tmp = tempfile::tempdir().unwrap();
1061        let oxi_dir = tmp.path().join(".oxi");
1062        fs::create_dir_all(&oxi_dir).unwrap();
1063        let settings_path = oxi_dir.join("settings.toml");
1064        // Write v3 format: default_model contains "provider/model"
1065        fs::write(
1066            &settings_path,
1067            "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1068        )
1069        .unwrap();
1070
1071        let settings = Settings::load_from(tmp.path()).unwrap();
1072        // Migration splits provider from model
1073        assert_eq!(settings.default_model, Some("gemini-2.0-flash".to_string()));
1074        assert_eq!(settings.default_provider, Some("google".to_string()));
1075    }
1076
1077    #[test]
1078    fn test_load_from_dir_no_config() {
1079        // Clean env vars that load_from() reads via apply_env()
1080        let _guard = EnvGuard::new(&[
1081            "OXI_MODEL",
1082            "OXI_PROVIDER",
1083            "OXI_THEME",
1084            "OXI_TOOL_TIMEOUT",
1085            "OXI_TEMPERATURE",
1086            "OXI_MAX_TOKENS",
1087            "OXI_SESSION_DIR",
1088            "OXI_STREAM",
1089            "OXI_EXTENSIONS_ENABLED",
1090        ]);
1091        let tmp = tempfile::tempdir().unwrap();
1092        let settings = Settings::load_from(tmp.path()).unwrap();
1093        // Falls back to defaults (may include global ~/.oxi/settings)
1094        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1095    }
1096
1097    // ── Environment variables ────────────────────────────────────────
1098
1099    #[test]
1100    fn test_from_env() {
1101        // NOTE: Environment variable overrides are disabled.
1102        // from_env() returns defaults only.
1103        let _guard = EnvGuard::new(&[
1104            // no env vars to clear
1105            "OXI_MODEL",
1106            "OXI_THEME",
1107            "OXI_TOOL_TIMEOUT",
1108            "OXI_PROVIDER",
1109            "OXI_DEFAULT_MODEL",
1110        ]);
1111
1112        let settings = Settings::from_env();
1113        // All fields should be at defaults since env overrides are disabled
1114        assert_eq!(settings.default_model, None);
1115        assert_eq!(settings.theme, "default");
1116        assert_eq!(settings.tool_timeout_seconds, 120);
1117    }
1118
1119    #[test]
1120    fn test_apply_env_boolish() {
1121        // NOTE: Environment variable overrides are disabled.
1122        // apply_env() is a no-op.
1123        let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1124        env::set_var("OXI_STREAM", "false");
1125        env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1126
1127        let mut settings = Settings::default();
1128        settings.apply_env();
1129        // Since env overrides are disabled, values stay at defaults
1130        assert!(settings.stream_responses); // default is true
1131        assert!(settings.extensions_enabled); // default is true
1132    }
1133
1134    #[test]
1135    fn test_apply_env_temperature() {
1136        // NOTE: Environment variable overrides are disabled.
1137        let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1138        env::set_var("OXI_TEMPERATURE", "0.7");
1139
1140        let mut settings = Settings::default();
1141        settings.apply_env();
1142        // Since env overrides are disabled, temperature stays at None
1143        assert_eq!(settings.default_temperature, None);
1144    }
1145
1146    #[test]
1147    fn test_env_does_not_override_when_unset() {
1148        let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1149        let settings = Settings::from_env();
1150        assert!(settings.default_model.is_none());
1151        assert!(settings.default_provider.is_none());
1152    }
1153
1154    // ── Helpers ──────────────────────────────────────────────────────
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_combines_provider_and_model() {
1194        let mut settings = Settings::default();
1195        settings.default_provider = Some("openai".to_string());
1196        settings.default_model = Some("gpt-4o".to_string());
1197        assert_eq!(
1198            settings.effective_model(None),
1199            Some("openai/gpt-4o".to_string())
1200        );
1201    }
1202
1203    #[test]
1204    fn test_effective_model_cli_overrides() {
1205        let mut settings = Settings::default();
1206        settings.default_provider = Some("openai".to_string());
1207        settings.default_model = Some("gpt-4o".to_string());
1208        assert_eq!(
1209            settings.effective_model(Some("anthropic/claude-3")),
1210            Some("anthropic/claude-3".to_string())
1211        );
1212    }
1213
1214    #[test]
1215    fn test_effective_model_no_provider_returns_bare() {
1216        let mut settings = Settings::default();
1217        settings.default_model = Some("gpt-4o".to_string());
1218        assert_eq!(settings.effective_model(None), Some("gpt-4o".to_string()));
1219    }
1220
1221    #[test]
1222    fn test_effective_model_falls_back_to_last_used() {
1223        let mut settings = Settings::default();
1224        settings.last_used_model = Some("anthropic/claude-3".to_string());
1225        assert_eq!(
1226            settings.effective_model(None),
1227            Some("anthropic/claude-3".to_string())
1228        );
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.default_model, Some("gpt-4o".to_string()));
1328        assert_eq!(migrated.default_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.default_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.default_model = Some("gpt-4o".to_string());
1358        original.default_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.default_model, original.default_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.default_model = Some("gpt-4o".to_string());
1400        settings.default_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.default_model, settings.default_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.default_model = Some("claude-3".to_string());
1420        settings.default_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.default_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.default_model = Some("gemini-pro".to_string());
1434        settings.default_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.default_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            "default_model": "gpt-4",
1448            "default_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.default_model, Some("gpt-4".to_string()));
1455        assert_eq!(settings.default_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#"
1466default_model = "claude-opus"
1467default_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.default_model, Some("claude-opus".to_string()));
1474        assert_eq!(settings.default_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            "default_model": "gpt-4o",
1487            "default_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.default_model, Some("gpt-4o".to_string()));
1495        assert_eq!(merged.default_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.default_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.default_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.default_model = Some("gpt-4o".to_string());
1525        settings.default_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.default_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.default_model = Some("gemini-pro".to_string());
1546        settings.default_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.default_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.default_model, Some("gemini-2.0-flash".to_string()));
1584        assert_eq!(settings.default_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#"{ "default_model": "json-model" }"#).unwrap();
1672        // TOML has model set to "toml-model"
1673        fs::write(&toml_path, r#"default_model = "toml-model""#).unwrap();
1674
1675        // JSON takes priority
1676        let settings = Settings::load_from(tmp.path()).unwrap();
1677        assert_eq!(settings.default_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#"
1685default_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.default_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}