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