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