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