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.
20///
21/// Version history:
22/// - 4: dynamic_models field + last_used_model/provider split
23/// - 5: output_languages field (TUI-only language policy)
24const SETTINGS_VERSION: u32 = 5;
25
26/// Known output channels for the TUI language policy.
27///
28/// `(key, prompt_label)` — `prompt_label` is the human-readable
29/// phrase used when building the system prompt directive.
30///
31/// New channels can be added by the user in `settings.toml`; the
32/// `KNOWN_CHANNELS` list is only the validation whitelist and the
33/// prompt-rendering label table. The runtime policy generator
34/// (`crate::prompt::system_prompt::language_directive`) walks this
35/// list to render each non-auto channel.
36pub const KNOWN_CHANNELS: &[(&str, &str)] = &[
37    ("response", "Your conversational responses to the user"),
38    (
39        "code_comment",
40        "Code comments you write (//, /* */, #, etc.)",
41    ),
42    (
43        "documentation",
44        "Documentation (markdown files, README, AGENTS.md, doc comments)",
45    ),
46    ("commit_message", "Git commit messages (subject + body)"),
47];
48
49/// Known language codes for the TUI language policy.
50///
51/// `(code, display_label)` — `code` is the ISO 639-1 value stored
52/// in `settings.toml`; `display_label` is shown in the UI and used
53/// in the rendered prompt directive.
54///
55/// `"auto"` is the special "match user's input language" sentinel
56/// (see `crate::prompt::system_prompt::language_directive`).
57/// Unknown codes are accepted at load time (with a warning) so that
58/// users can add languages without code changes.
59pub const KNOWN_LANGS: &[(&str, &str)] = &[
60    ("auto", "Auto (match user)"),
61    ("en", "English"),
62    ("ko", "Korean (한국어)"),
63    ("ja", "Japanese (日本語)"),
64    ("zh", "Chinese (中文)"),
65    ("es", "Spanish"),
66    ("fr", "French"),
67    ("de", "German"),
68];
69
70/// Environment variable prefix for oxi settings.
71/// Keep: reserved for future env-based config loading (e.g. OXI_API_KEY).
72#[allow(dead_code)]
73const ENV_PREFIX: &str = "OXI_";
74
75/// Thinking level for agent responses
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
77#[serde(rename_all = "snake_case")]
78pub enum ThinkingLevel {
79    /// Extended reasoning disabled (default).
80    #[default]
81    Off,
82    /// Minimal reasoning.
83    Minimal,
84    /// Low reasoning.
85    Low,
86    /// Medium reasoning.
87    Medium,
88    /// High reasoning.
89    High,
90    /// Very high reasoning.
91    XHigh,
92}
93
94/// A custom OpenAI-compatible provider configuration.
95///
96/// Custom providers are loaded from `~/.oxi/settings.toml` via `[[custom_provider]]` sections
97/// and registered at runtime so that models like `minimax/minimax-m2.5` can be used directly.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CustomProvider {
100    /// Unique provider name (e.g. `"minimax"`).
101    pub name: String,
102    /// Base URL of the OpenAI-compatible API (e.g. `"https://api.minimax.chat/v1"`).
103    pub base_url: String,
104    /// Environment variable name that holds the API key (e.g. `"MINIMAX_API_KEY"`).
105    pub api_key_env: String,
106    /// API dialect: `"openai-completions"` or `"openai-responses"`.
107    #[serde(default = "default_custom_provider_api")]
108    pub api: String,
109}
110
111fn default_custom_provider_api() -> String {
112    "openai-completions".to_string()
113}
114
115/// Application settings
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Settings {
118    // ── Version (for migration) ──────────────────────────────────────
119    /// Settings format version. Used for automatic migration.
120    #[serde(default)]
121    pub version: u32,
122
123    // ── Core LLM settings ───────────────────────────────────────────
124    /// Thinking level for agent responses
125    #[serde(default = "default_thinking_level")]
126    pub thinking_level: ThinkingLevel,
127
128    /// Color theme (e.g., "default", "monokai", "dracula")
129    #[serde(default = "default_theme")]
130    pub theme: String,
131
132    /// Deprecated: use `last_used_model` instead. Kept for serde backward compat.
133    #[serde(default, skip_serializing)]
134    pub default_model: Option<String>,
135
136    /// Deprecated: use `last_used_provider` instead. Kept for serde backward compat.
137    #[serde(default, skip_serializing)]
138    pub default_provider: Option<String>,
139
140    /// Model selected by the user (last used = current default).
141    /// Set during onboarding and updated every time the user switches model.
142    #[serde(default)]
143    pub last_used_model: Option<String>,
144
145    /// Provider for the last used model.
146    #[serde(default)]
147    pub last_used_provider: Option<String>,
148
149    /// Max tokens for responses
150    pub max_tokens: Option<u32>,
151
152    /// Temperature for generation (0.0–2.0)
153    pub temperature: Option<f32>,
154
155    /// Default temperature as f64 (higher precision, takes precedence over `temperature`)
156    pub default_temperature: Option<f64>,
157
158    /// Maximum tokens for generation (usize variant, takes precedence over `max_tokens`)
159    pub max_response_tokens: Option<usize>,
160
161    // ── Session settings ─────────────────────────────────────────────
162    /// Session history size (entries to keep in memory)
163    #[serde(default = "default_session_history_size")]
164    pub session_history_size: usize,
165
166    /// Directory for storing sessions (default: `~/.oxi/sessions`)
167    pub session_dir: Option<PathBuf>,
168
169    // ── Behaviour flags ──────────────────────────────────────────────
170    /// Whether to stream responses
171    #[serde(default = "default_true")]
172    pub stream_responses: bool,
173
174    /// Whether extensions are enabled
175    #[serde(default = "default_true")]
176    pub extensions_enabled: bool,
177
178    /// Whether to auto-compact conversations that exceed context window
179    #[serde(default = "default_true")]
180    pub auto_compaction: bool,
181
182    /// Built-in tools to disable (by name, e.g. `["web_search", "github_search"]`).
183    /// All tools are enabled by default; list tools here to turn them off.
184    #[serde(default)]
185    pub disabled_tools: Vec<String>,
186
187    // ── Timeouts ─────────────────────────────────────────────────────
188    /// Timeout in seconds for tool execution
189    #[serde(default = "default_tool_timeout")]
190    pub tool_timeout_seconds: u64,
191
192    // ── Resource lists (managed by `oxi config`) ────────────────────
193    /// List of extension paths or npm package sources to load
194    #[serde(default)]
195    pub extensions: Vec<String>,
196
197    /// List of skill paths or npm package sources to load
198    #[serde(default)]
199    pub skills: Vec<String>,
200
201    /// List of prompt template paths to load
202    #[serde(default)]
203    pub prompts: Vec<String>,
204
205    /// List of theme paths to load
206    #[serde(default)]
207    pub themes: Vec<String>,
208
209    // ── Custom OpenAI-compatible providers ──────────────────────────────
210    /// Registered custom providers (loaded from `[[custom_provider]]` TOML sections).
211    #[serde(default)]
212    pub custom_providers: Vec<CustomProvider>,
213
214    // ── Dynamic model cache ─────────────────────────────────────────────
215    /// Cached model lists fetched from provider `/models` endpoints.
216    /// Key is the provider name, value is a list of model IDs.
217    /// Updated when API keys are entered in setup wizard or on demand.
218    #[serde(default)]
219    pub dynamic_models: HashMap<String, Vec<String>>,
220
221    // ── Multi-provider routing ─────────────────────────────────────────
222    /// Enable automatic complexity-based routing
223    #[serde(default = "default_false")]
224    pub enable_routing: bool,
225
226    /// Router profile name to use (e.g., "auto", "balanced").
227    #[serde(default)]
228    pub router_profile: Option<String>,
229
230    /// Prefer cost-efficient models when routing
231    #[serde(default = "default_true")]
232    pub prefer_cost_efficient: bool,
233
234    /// Fallback chain: ordered list of model IDs to try on failure
235    #[serde(default)]
236    pub fallback_chain: Vec<String>,
237
238    /// Whether to use provider fallback on errors (false = fail fast)
239    #[serde(default = "default_true")]
240    pub enable_fallback: bool,
241
242    /// Disable automatic fallback (same as enable_fallback = false)
243    #[serde(default)]
244    pub disable_fallback: bool,
245
246    /// Circuit breaker failure threshold per provider
247    #[serde(default = "default_circuit_failure_threshold")]
248    pub circuit_breaker_failure_threshold: u32,
249
250    /// Circuit breaker open duration in seconds
251    #[serde(default = "default_circuit_open_duration_secs")]
252    pub circuit_breaker_open_duration_secs: u64,
253
254    // ── Keybindings ────────────────────────────────────────────────────
255    /// User-defined keybinding overrides.
256    /// Format: `{ "ActionName": ["Ctrl+x", "Alt+y"] }`
257    /// Actions are matched case-insensitively to the Action enum in oxi-tui.
258    #[serde(default)]
259    pub keybindings: HashMap<String, Vec<String>>,
260
261    // ── TUI output language policy (TUI-only) ─────────────────────────
262    /// Per-channel output language for the TUI agent loop.
263    ///
264    /// Maps a channel key (e.g. `"response"`, `"code_comment"`,
265    /// `"documentation"`, `"commit_message"`) to a language code
266    /// (e.g. `"en"`, `"ko"`, or `"auto"`).
267    ///
268    /// **Scope:** This setting is consumed exclusively by
269    /// `crate::app::agent_session_runtime::build_system_prompt` (the
270    /// TUI session build path). The `lib.rs` App build path used by
271    /// `oxi --print` and RPC mode **does not** inject the policy,
272    /// so this setting is silently ignored in non-TUI modes.
273    ///
274    /// **Default:** Empty map. Every channel defaults to `"auto"`
275    /// (match the most recent user message language), preserving
276    /// the previous behavior. Set a channel to a non-`"auto"`
277    /// value to fix its output language.
278    ///
279    /// **Extension map:** User-defined channels beyond the four in
280    /// `KNOWN_CHANNELS` are accepted (e.g. `pr_description = "en"`).
281    /// Unknown channels fall back to using the raw key as the label
282    /// in the rendered directive, and the model typically still
283    /// understands from context.
284    ///
285    /// **Strong default, NOT a hard guarantee.** This setting drives
286    /// a prompt-level "MUST" directive at the end of the system
287    /// prompt and a `"Focus areas:"` instruction passed to the
288    /// compaction summarizer. Both are prompt-level signals — the
289    /// model can still occasionally violate the policy when:
290    ///
291    ///   - the context grows long and the directive is "lost in the
292    ///     middle";
293    ///   - tool output is echoed verbatim without translation;
294    ///   - subagent summarization under a different framing weakens
295    ///     the instruction (see `build_compaction_instruction` for
296    ///     the exact framing caveat).
297    ///
298    /// If a 100% guarantee is required, additional layers (tool
299    /// output wrapping, response post-processing) are needed — out
300    /// of scope for this MVP.
301    ///
302    /// **Validation:** Unknown language codes are logged at warn
303    /// level and kept (so users can add languages without code
304    /// changes). Channel keys are not validated — see "Extension
305    /// map" above.
306    #[serde(default)]
307    pub output_languages: HashMap<String, String>,
308}
309
310fn default_theme() -> String {
311    "default".to_string()
312}
313
314fn default_thinking_level() -> ThinkingLevel {
315    ThinkingLevel::Medium
316}
317
318fn default_session_history_size() -> usize {
319    100
320}
321
322fn default_true() -> bool {
323    true
324}
325
326fn default_false() -> bool {
327    false
328}
329
330fn default_circuit_failure_threshold() -> u32 {
331    5
332}
333
334fn default_circuit_open_duration_secs() -> u64 {
335    30
336}
337
338fn default_tool_timeout() -> u64 {
339    120
340}
341
342impl Default for Settings {
343    fn default() -> Self {
344        Self {
345            version: SETTINGS_VERSION,
346            thinking_level: ThinkingLevel::Medium,
347            theme: default_theme(),
348            last_used_model: None,
349            last_used_provider: None,
350            default_model: None,
351            default_provider: None,
352            max_tokens: None,
353            temperature: None,
354            default_temperature: None,
355            max_response_tokens: None,
356            session_history_size: default_session_history_size(),
357            session_dir: None,
358            stream_responses: true,
359            extensions_enabled: true,
360            auto_compaction: true,
361            disabled_tools: Vec::new(),
362            tool_timeout_seconds: default_tool_timeout(),
363            extensions: Vec::new(),
364            skills: Vec::new(),
365            prompts: Vec::new(),
366            themes: Vec::new(),
367            custom_providers: Vec::new(),
368            dynamic_models: HashMap::new(),
369            // Multi-provider routing defaults
370            enable_routing: false,
371            router_profile: None,
372            prefer_cost_efficient: true,
373            fallback_chain: Vec::new(),
374            enable_fallback: true,
375            disable_fallback: false,
376            circuit_breaker_failure_threshold: 5,
377            circuit_breaker_open_duration_secs: 30,
378            keybindings: HashMap::new(),
379            output_languages: HashMap::new(),
380        }
381    }
382}
383
384impl Settings {
385    // ── Paths ────────────────────────────────────────────────────────
386
387    /// Get the global settings directory path (`~/.oxi`).
388    pub fn settings_dir() -> Result<PathBuf> {
389        let base = dirs::home_dir().context("Cannot determine home directory")?;
390        Ok(base.join(".oxi"))
391    }
392
393    /// Get the global settings TOML file path (`~/.oxi/settings.toml`).
394    pub fn settings_toml_path() -> Result<PathBuf> {
395        Ok(Self::settings_dir()?.join("settings.toml"))
396    }
397
398    /// Get the global settings JSON file path (`~/.oxi/settings.json`).
399    pub fn settings_json_path() -> Result<PathBuf> {
400        Ok(Self::settings_dir()?.join("settings.json"))
401    }
402
403    /// Get the global settings file path (JSON takes priority).
404    ///
405    /// Returns the path to the settings file that should be used.
406    /// If both JSON and TOML exist, JSON is returned (takes priority).
407    /// If only one exists, that path is returned.
408    /// If neither exists, returns the JSON path by default.
409    pub fn settings_path() -> Result<PathBuf> {
410        let json_path = Self::settings_json_path()?;
411        let toml_path = Self::settings_toml_path()?;
412
413        if json_path.exists() && toml_path.exists() {
414            // Both exist: JSON takes priority
415            tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
416            return Ok(json_path);
417        }
418
419        if json_path.exists() {
420            return Ok(json_path);
421        }
422
423        if toml_path.exists() {
424            return Ok(toml_path);
425        }
426
427        // Neither exists: default to JSON
428        Ok(json_path)
429    }
430
431    /// Get the effective settings file path, preferring the specified format.
432    ///
433    /// If `prefer_json` is true, checks JSON first; otherwise checks TOML first.
434    /// Returns the first existing file, or the preferred path if neither exists.
435    pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
436        let json_path = Self::settings_json_path()?;
437        let toml_path = Self::settings_toml_path()?;
438
439        let (primary, secondary) = if prefer_json {
440            (&json_path, &toml_path)
441        } else {
442            (&toml_path, &json_path)
443        };
444
445        if primary.exists() {
446            return Ok(primary.clone());
447        }
448
449        if secondary.exists() {
450            return Ok(secondary.clone());
451        }
452
453        // Neither exists: return preferred path
454        Ok(primary.clone())
455    }
456
457    /// Detect the settings file format from its path.
458    pub fn detect_format(path: &Path) -> SettingsFormat {
459        match path.extension().and_then(|e| e.to_str()) {
460            Some("json") => SettingsFormat::Json,
461            Some("toml") => SettingsFormat::Toml,
462            _ => SettingsFormat::Json, // Default to JSON for unknown extensions
463        }
464    }
465
466    /// Get the project-local settings file path.
467    ///
468    /// Searches for `.oxi/settings.json` first, then `.oxi/settings.toml`.
469    /// Returns the first one found, or None if neither exists.
470    pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
471        let mut dir = start_dir.to_path_buf();
472        loop {
473            // Check JSON first (priority), then TOML
474            let json_candidate = dir.join(".oxi").join("settings.json");
475            if json_candidate.exists() {
476                return Some(json_candidate);
477            }
478
479            let toml_candidate = dir.join(".oxi").join("settings.toml");
480            if toml_candidate.exists() {
481                return Some(toml_candidate);
482            }
483
484            if !dir.pop() {
485                return None;
486            }
487        }
488    }
489
490    /// Resolve the effective session directory.
491    ///
492    /// Priority: `session_dir` field → `~/.oxi/sessions`.
493    pub fn effective_session_dir(&self) -> Result<PathBuf> {
494        if let Some(ref dir) = self.session_dir {
495            return Ok(dir.clone());
496        }
497        Ok(Self::settings_dir()?.join("sessions"))
498    }
499
500    // ── Loading ──────────────────────────────────────────────────────
501
502    /// Load settings, applying all layers:
503    ///
504    /// 1. Built-in defaults
505    /// 2. Global `~/.oxi/settings.toml`
506    /// 3. Project `.oxi/settings.toml`
507    /// 4. Environment variable overrides
508    ///
509    /// # Examples
510    ///
511    /// ```ignore
512    /// use oxi_cli::Settings;
513    ///
514    /// let settings = Settings::load().expect("Failed to load settings");
515    /// println!("Using model: {}", settings.effective_model(None));
516    /// ```
517    pub fn load() -> Result<Self> {
518        Self::load_from_cwd()
519    }
520
521    /// Load settings with an explicit working directory for project config discovery.
522    pub fn load_from(dir: &std::path::Path) -> Result<Self> {
523        // 1. Start from defaults
524        let mut settings = Settings::default();
525
526        // 2. Layer global config
527        if let Ok(global_path) = Self::settings_path()
528            && global_path.exists()
529        {
530            settings = Self::layer_file(&settings, &global_path)?;
531        }
532
533        // 3. Layer project config
534        if let Some(project_path) = Self::find_project_settings(dir) {
535            settings = Self::layer_file(&settings, &project_path)?;
536        }
537
538        // 4. Layer environment variables
539        settings.apply_env();
540
541        // 5. Run migration if needed
542        settings = Self::migrate(settings)?;
543
544        // 6. Validate TUI-specific language policy
545        settings.validate_output_languages();
546
547        Ok(settings)
548    }
549
550    /// Warn on unknown `output_languages` language codes. Channel
551    /// keys are **not** validated: any channel (known or user-defined)
552    /// is accepted so users can add new channels in `settings.toml`
553    /// without code changes. `KNOWN_CHANNELS` provides a label table
554    /// for the prompt directive; unknown channels fall back to using
555    /// the raw key as the label.
556    fn validate_output_languages(&mut self) {
557        if self.output_languages.is_empty() {
558            return;
559        }
560        let known_langs: std::collections::HashSet<&str> =
561            KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
562
563        for (channel, lang) in &self.output_languages {
564            if !known_langs.contains(lang.as_str()) {
565                tracing::warn!(
566                    "Unknown output_languages language code '{}' for channel '{}'. \
567                     Keeping as-is (the model will likely understand).",
568                    lang,
569                    channel
570                );
571            }
572        }
573    }
574
575    /// Convenience: load from current working directory.
576    pub fn load_from_cwd() -> Result<Self> {
577        let cwd = env::current_dir().context("Cannot determine current directory")?;
578        Self::load_from(&cwd)
579    }
580
581    /// Parse a settings file (TOML or JSON) and overlay its values onto `base`.
582    ///
583    /// The format is auto-detected based on the file extension.
584    /// Fields present in the file replace those in `base`; absent fields
585    /// are left untouched.
586    fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
587        let content = fs::read_to_string(path)
588            .with_context(|| format!("Failed to read settings from {}", path.display()))?;
589
590        let format = Self::detect_format(path);
591        let overlay: serde_json::Value = match format {
592            SettingsFormat::Toml => {
593                let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
594                    format!("Failed to parse TOML settings from {}", path.display())
595                })?;
596                // Convert TOML to JSON Value for uniform merging
597                toml_value_to_json(toml_value)
598            }
599            SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
600                format!("Failed to parse JSON settings from {}", path.display())
601            })?,
602        };
603
604        // Re-serialize the base to JSON, merge with the overlay, then
605        // deserialize back. This gives correct "only override what's
606        // present" semantics.
607        let base_json =
608            serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
609
610        let merged = merge_json_values(base_json, overlay);
611        let result: Settings =
612            serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
613
614        Ok(result)
615    }
616
617    // ── Environment variables ────────────────────────────────────────
618
619    /// Apply environment variable overrides in-place.
620    ///
621    /// DEPRECATED: Environment variable overrides are being phased out in favor
622    /// of file-based configuration (`~/.oxi/settings.toml`). This method is
623    /// kept for CI/CD compatibility but should not be relied upon for local
624    /// development. Use `oxi config set` or `oxi setup` instead.
625    ///
626    /// Supported variables (CI/CD only):
627    ///
628    /// | Env var                    | Setting                |
629    /// |---------------------------|------------------------|
630    /// | `OXI_MODEL`               | `default_model`        |
631    /// | `OXI_PROVIDER`            | `default_provider`     |
632    /// | `OXI_THINKING`            | `thinking_level`       |
633    /// | `OXI_THEME`               | `theme`                |
634    /// | `OXI_MAX_TOKENS`          | `max_tokens`           |
635    /// | `OXI_TEMPERATURE`         | `default_temperature`  |
636    /// | `OXI_SESSION_DIR`         | `session_dir`          |
637    /// | `OXI_STREAM`              | `stream_responses`     |
638    /// | `OXI_EXTENSIONS_ENABLED`  | `extensions_enabled`   |
639    /// | `OXI_AUTO_COMPACTION`     | `auto_compaction`      |
640    /// | `OXI_TOOL_TIMEOUT`        | `tool_timeout_seconds` |
641    /// | `OXI_DISABLED_TOOLS`      | `disabled_tools`       |
642    #[allow(dead_code)]
643    pub fn apply_env(&mut self) {
644        // No-op: environment variable overrides are disabled.
645        // All configuration should come from settings.toml / settings.json.
646        // This method is kept for backward compatibility but does nothing.
647    }
648
649    /// Build a `Settings` instance from **only** environment variables
650    /// (all other fields stay at defaults).
651    ///
652    /// DEPRECATED: Returns defaults since env overrides are disabled.
653    /// Use `Settings::load()` to load from settings.toml instead.
654    #[allow(dead_code)]
655    pub fn from_env() -> Self {
656        Self::default()
657    }
658
659    // ── Persistence ──────────────────────────────────────────────────
660
661    /// Save settings to the global config file.
662    ///
663    /// Uses the format of the existing file if present, otherwise saves as JSON.
664    /// Preserves backward compatibility with existing TOML files.
665    pub fn save(&self) -> Result<()> {
666        let dir = Self::settings_dir()?;
667        let path = Self::settings_path()?;
668
669        if !dir.exists() {
670            fs::create_dir_all(&dir).with_context(|| {
671                format!("Failed to create settings directory {}", dir.display())
672            })?;
673        }
674
675        let format = Self::detect_format(&path);
676        let content = Self::serialize_for_format(self, format)?;
677
678        // Atomic write: write to temp file first, then rename
679        let tmp_path = path.with_extension("tmp");
680        fs::write(&tmp_path, &content)
681            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
682        fs::rename(&tmp_path, &path)
683            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
684
685        Ok(())
686    }
687
688    /// Save settings to a specific path, using the format determined by the file extension.
689    pub fn save_to(&self, path: &Path) -> Result<()> {
690        if let Some(parent) = path.parent()
691            && !parent.exists()
692        {
693            fs::create_dir_all(parent)
694                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
695        }
696
697        let format = Self::detect_format(path);
698        let content = Self::serialize_for_format(self, format)?;
699
700        // Atomic write
701        let tmp_path = path.with_extension("tmp");
702        fs::write(&tmp_path, &content)
703            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
704        fs::rename(&tmp_path, path)
705            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
706
707        Ok(())
708    }
709
710    /// Save settings to the project-local config file.
711    ///
712    /// Uses the format of the existing file if present, otherwise saves as JSON.
713    pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
714        let dir = project_dir.join(".oxi");
715
716        if !dir.exists() {
717            fs::create_dir_all(&dir).with_context(|| {
718                format!(
719                    "Failed to create project settings directory {}",
720                    dir.display()
721                )
722            })?;
723        }
724
725        // Check if a settings file already exists in project
726        let json_path = dir.join("settings.json");
727        let toml_path = dir.join("settings.toml");
728
729        let path = if json_path.exists() {
730            &json_path
731        } else if toml_path.exists() {
732            &toml_path
733        } else {
734            // Default to JSON for new files
735            &json_path
736        };
737
738        let format = Self::detect_format(path);
739        let content = Self::serialize_for_format(self, format)?;
740
741        // Atomic write
742        let tmp_path = path.with_extension("tmp");
743        fs::write(&tmp_path, &content)
744            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
745        fs::rename(&tmp_path, path)
746            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
747
748        Ok(())
749    }
750
751    /// Serialize settings to a string in the specified format.
752    pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
753        match format {
754            SettingsFormat::Toml => {
755                toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
756            }
757            SettingsFormat::Json => serde_json::to_string_pretty(settings)
758                .context("Failed to serialize settings to JSON"),
759        }
760    }
761
762    /// Parse settings from a string in the specified format.
763    pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
764        match format {
765            SettingsFormat::Toml => {
766                toml::from_str(content).context("Failed to parse TOML settings")
767            }
768            SettingsFormat::Json => {
769                serde_json::from_str(content).context("Failed to parse JSON settings")
770            }
771        }
772    }
773
774    // ── CLI overrides ────────────────────────────────────────────────
775
776    /// Merge with CLI arguments (CLI takes precedence).
777    ///
778    /// # Arguments
779    ///
780    /// * `model` — CLI-specified model override
781    /// * `provider` — CLI-specified provider override
782    /// * `enable_routing` — CLI-specified enable_routing override
783    /// * `prefer_cost_efficient` — CLI-specified prefer_cost_efficient override
784    /// * `fallback_chain` — CLI-specified fallback chain override
785    /// * `disable_fallback` — CLI-specified disable_fallback override
786    pub fn merge_cli(
787        &mut self,
788        model: Option<String>,
789        provider: Option<String>,
790        enable_routing: Option<bool>,
791        prefer_cost_efficient: Option<bool>,
792        fallback_chain: Option<Vec<String>>,
793        disable_fallback: Option<bool>,
794    ) {
795        if let Some(m) = model {
796            self.last_used_model = Some(m);
797        }
798        if let Some(p) = provider {
799            self.last_used_provider = Some(p);
800        }
801        if let Some(r) = enable_routing {
802            self.enable_routing = r;
803        }
804        if let Some(p) = prefer_cost_efficient {
805            self.prefer_cost_efficient = p;
806        }
807        if let Some(fc) = fallback_chain
808            && !fc.is_empty()
809        {
810            self.fallback_chain = fc;
811        }
812        if let Some(df) = disable_fallback {
813            self.disable_fallback = df;
814            // If disable_fallback is true, disable fallback
815            if df {
816                self.enable_fallback = false;
817            }
818        }
819    }
820
821    /// Get the effective model ID (provider/model format).
822    /// Returns None if no model is configured.
823    pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
824        cli_model.map(String::from).or_else(|| {
825            // Reconstruct full model ID from separate fields.
826            // Handles both cases:
827            //   - last_used_model = "anthropic/claude-sonnet-4" (full ID, stored by save_last_used)
828            //   - last_used_model = "claude-sonnet-4" + last_used_provider = "anthropic" (split)
829            let model = self.last_used_model.as_ref()?;
830            if model.contains('/') {
831                // Already a full model ID
832                Some(model.clone())
833            } else if let Some(ref provider) = self.last_used_provider {
834                // Reconstruct from separate fields
835                Some(format!("{}/{}", provider, model))
836            } else {
837                Some(model.clone())
838            }
839        })
840    }
841
842    /// Get the effective provider.
843    /// Returns None if no provider is configured.
844    pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
845        cli_provider
846            .map(String::from)
847            .or_else(|| self.last_used_provider.clone())
848    }
849
850    /// Get the effective temperature, preferring `default_temperature` (f64)
851    /// over `temperature` (f32), falling back to `None`.
852    pub fn effective_temperature(&self) -> Option<f64> {
853        self.default_temperature
854            .or(self.temperature.map(|t| t as f64))
855    }
856
857    /// Get the effective max tokens, preferring `max_response_tokens` (usize)
858    /// over `max_tokens` (u32), falling back to `None`.
859    pub fn effective_max_tokens(&self) -> Option<usize> {
860        self.max_response_tokens
861            .or(self.max_tokens.map(|t| t as usize))
862    }
863
864    /// Get the configured router profile name.
865    pub fn router_profile(&self) -> Option<&str> {
866        self.router_profile.as_deref()
867    }
868
869    // ── Theme persistence ─────────────────────────────────────────────
870
871    /// Save the last used model/provider and persist to disk.
872    ///
873    /// Splits the model_id on first `/` to store provider and model separately.
874    pub fn save_last_used(model_id: &str) {
875        if let Ok(mut settings) = Self::load() {
876            if let Some((provider, model)) = model_id.split_once('/') {
877                settings.last_used_provider = Some(provider.to_string());
878                settings.last_used_model = Some(model.to_string());
879            } else {
880                settings.last_used_model = Some(model_id.to_string());
881            }
882            let _ = settings.save();
883        }
884    }
885
886    /// Save the current theme to settings and persist to disk.
887    pub fn save_theme(&mut self, name: &str) -> Result<()> {
888        self.theme = name.to_string();
889        self.save()
890    }
891
892    /// Get the theme name from settings, returning a default if not set.
893    pub fn get_theme_name(&self) -> String {
894        if self.theme.is_empty() || self.theme == "default" {
895            "oxi_dark".to_string()
896        } else {
897            self.theme.clone()
898        }
899    }
900
901    // ── Migration ────────────────────────────────────────────────────
902
903    /// Migrate settings from an older format version to the current one.
904    ///
905    /// Currently handles:
906    /// - Version 0 → Version 2 (adds JSON support, version bump)
907    /// - Version 1 → Version 2 (adds JSON support)
908    /// - Version 3 → Version 4 (default_model → last_used_model)
909    /// - Version 4 → Version 5 (output_languages field added — no
910    ///   value migration, serde default fills with empty map)
911    fn migrate(settings: Settings) -> Result<Settings> {
912        let mut settings = settings;
913
914        match settings.version {
915            SETTINGS_VERSION => {
916                // Already current — nothing to do.
917            }
918            0 => {
919                // Version 0 = pre-versioning config.
920                // Add any defaults that were introduced in version 1.
921                if settings.tool_timeout_seconds == 0 {
922                    settings.tool_timeout_seconds = default_tool_timeout();
923                }
924                settings.version = SETTINGS_VERSION;
925
926                tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
927            }
928            1 | 2 => {
929                // Version 1/2 → 5: dynamic_models field added + model/provider split
930                // (back-compat path that also covers the 4 → 5 no-op for output_languages).
931                settings.version = SETTINGS_VERSION;
932                tracing::info!(
933                    "Migrated settings from version {} to {}",
934                    settings.version,
935                    SETTINGS_VERSION
936                );
937            }
938            3 => {
939                // Version 3 → 4: migrate default_model → last_used_model.
940                if let Some(model) = settings.default_model.take() {
941                    if let Some((provider, model_name)) = model.split_once('/') {
942                        settings.last_used_provider = Some(provider.to_string());
943                        settings.last_used_model = Some(model_name.to_string());
944                    } else {
945                        settings.last_used_model = Some(model);
946                    }
947                }
948                settings.version = SETTINGS_VERSION;
949                tracing::info!(
950                    "Migrated settings from version 3 to {} (default_model → last_used_model)",
951                    SETTINGS_VERSION
952                );
953            }
954            4 => {
955                // Version 4 → 5: output_languages field added.
956                // No value migration needed — `#[serde(default)]` fills
957                // the missing field with an empty map (all channels = "auto"),
958                // which is the pre-feature behavior.
959                settings.version = SETTINGS_VERSION;
960                tracing::info!(
961                    "Migrated settings from version 4 to {} (added output_languages, defaulting all channels to auto)",
962                    SETTINGS_VERSION
963                );
964            }
965            v if v > SETTINGS_VERSION => {
966                // Future version — we don't know how to downgrade.
967                anyhow::bail!(
968                    "Settings version {} is newer than supported version {}. \
969                     Please update oxi.",
970                    v,
971                    SETTINGS_VERSION
972                );
973            }
974            v => {
975                // Unknown old version — best-effort migration.
976                tracing::warn!(
977                    "Unknown settings version {}, attempting migration to {}",
978                    v,
979                    SETTINGS_VERSION
980                );
981                settings.version = SETTINGS_VERSION;
982            }
983        }
984
985        Ok(settings)
986    }
987}
988
989// ── Settings format detection ──────────────────────────────────────
990
991/// Supported settings file formats.
992#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
993pub enum SettingsFormat {
994    /// JSON format.
995    #[default]
996    Json,
997    /// TOML format.
998    Toml,
999}
1000
1001impl SettingsFormat {
1002    /// Get the file extension for this format.
1003    pub fn extension(&self) -> &'static str {
1004        match self {
1005            SettingsFormat::Json => "json",
1006            SettingsFormat::Toml => "toml",
1007        }
1008    }
1009}
1010
1011// ── JSON/TOML conversion helpers ────────────────────────────────────
1012
1013/// Convert a TOML Value to a serde_json::Value.
1014fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1015    match toml {
1016        toml::Value::String(s) => serde_json::Value::String(s),
1017        toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1018        toml::Value::Float(f) => serde_json::Number::from_f64(f)
1019            .map(serde_json::Value::Number)
1020            .unwrap_or(serde_json::Value::Null),
1021        toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1022        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1023        toml::Value::Array(arr) => {
1024            serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1025        }
1026        toml::Value::Table(table) => {
1027            let obj = table
1028                .into_iter()
1029                .map(|(k, v)| (k, toml_value_to_json(v)))
1030                .collect();
1031            serde_json::Value::Object(obj)
1032        }
1033    }
1034}
1035
1036/// Deep merge two JSON values. The second value overrides the first.
1037fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1038    match (base, override_) {
1039        // If either is not an object, the override wins
1040        (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1041            let mut result = base_map;
1042            for (key, override_value) in override_map {
1043                let base_value = result.remove(&key);
1044                let merged = match base_value {
1045                    Some(base_v) => merge_json_values(base_v, override_value),
1046                    None => override_value,
1047                };
1048                result.insert(key, merged);
1049            }
1050            serde_json::Value::Object(result)
1051        }
1052        // Override wins for non-objects
1053        (_, override_) => override_,
1054    }
1055}
1056
1057/// Parse a thinking level from a string.
1058pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1059    match s.to_lowercase().as_str() {
1060        "off" | "none" => Some(ThinkingLevel::Off),
1061        "minimal" => Some(ThinkingLevel::Minimal),
1062        "low" => Some(ThinkingLevel::Low),
1063        "medium" | "standard" => Some(ThinkingLevel::Medium),
1064        "high" | "thorough" => Some(ThinkingLevel::High),
1065        "xhigh" => Some(ThinkingLevel::XHigh),
1066        _ => None,
1067    }
1068}
1069
1070/// Parse a boolean-like string (`"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"`).
1071#[allow(dead_code)]
1072fn parse_boolish(s: &str) -> Result<bool> {
1073    match s.to_lowercase().as_str() {
1074        "true" | "1" | "yes" | "on" => Ok(true),
1075        "false" | "0" | "no" | "off" => Ok(false),
1076        _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1077    }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083    use std::io::Write as IoWrite;
1084    use std::sync::Mutex;
1085
1086    /// Global lock to serialize all tests that manipulate process-wide env vars.
1087    #[allow(dead_code)] // held implicitly via guard pattern; not all tests acquire it
1088    static ENV_LOCK: Mutex<()> = Mutex::new(());
1089
1090    /// RAII guard that removes listed env vars on creation and restores them on drop.
1091    /// This prevents parallel test races where one test sets an env var that leaks into another.
1092    struct EnvGuard {
1093        saved: Vec<(String, Option<String>)>,
1094    }
1095
1096    impl EnvGuard {
1097        fn new(vars: &[&str]) -> Self {
1098            let saved = vars
1099                .iter()
1100                .map(|&name| {
1101                    let old = env::var(name).ok();
1102                    // SAFETY: test-only; the ENV_LOCK mutex serializes access.
1103                    unsafe { env::remove_var(name) };
1104                    (name.to_string(), old)
1105                })
1106                .collect();
1107            Self { saved }
1108        }
1109    }
1110
1111    impl Drop for EnvGuard {
1112        fn drop(&mut self) {
1113            for (name, old) in self.saved.drain(..) {
1114                match old {
1115                    // SAFETY: test-only; the ENV_LOCK mutex serializes access.
1116                    Some(val) => unsafe { env::set_var(&name, val) },
1117                    None => unsafe { env::remove_var(&name) },
1118                }
1119            }
1120        }
1121    }
1122
1123    // ── Struct tests ─────────────────────────────────────────────────
1124
1125    #[test]
1126    fn test_default_settings() {
1127        let settings = Settings::default();
1128        assert_eq!(settings.version, SETTINGS_VERSION);
1129        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1130        assert_eq!(settings.theme, "default");
1131        assert!(settings.last_used_model.is_none());
1132        assert!(settings.last_used_provider.is_none());
1133        assert!(settings.extensions_enabled);
1134        assert!(settings.auto_compaction);
1135        assert_eq!(settings.tool_timeout_seconds, 120);
1136        assert!(settings.stream_responses);
1137    }
1138
1139    #[test]
1140    fn test_merge_cli() {
1141        let mut settings = Settings::default();
1142        settings.last_used_model = Some("gpt-4o".to_string());
1143
1144        settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1145        assert_eq!(settings.last_used_model, Some("claude".to_string()));
1146
1147        settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1148        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1149
1150        // Test routing flags
1151        settings.merge_cli(
1152            None,
1153            None,
1154            Some(true),
1155            Some(false),
1156            Some(vec!["openai/gpt-4o".to_string()]),
1157            Some(false),
1158        );
1159        assert!(settings.enable_routing);
1160        assert!(!settings.prefer_cost_efficient);
1161        assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1162        assert!(!settings.disable_fallback);
1163
1164        // Test disable_fallback sets enable_fallback to false
1165        let mut settings2 = Settings::default();
1166        settings2.merge_cli(None, None, None, None, None, Some(true));
1167        assert!(settings2.disable_fallback);
1168        assert!(!settings2.enable_fallback);
1169    }
1170
1171    // ── Layered loading ──────────────────────────────────────────────
1172
1173    #[test]
1174    fn test_layer_file_overrides() {
1175        let base = Settings::default();
1176
1177        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1178        let toml_content = r#"
1179last_used_model = "openai/gpt-4o"
1180theme = "dracula"
1181"#;
1182        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1183
1184        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1185        assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1186        assert_eq!(merged.theme, "dracula");
1187        // Unchanged fields retain defaults
1188        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1189        assert!(merged.extensions_enabled);
1190    }
1191
1192    #[test]
1193    fn test_layer_file_preserves_unset() {
1194        let mut base = Settings::default();
1195        base.last_used_provider = Some("deepseek".to_string());
1196
1197        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1198        // Only override theme — provider should remain
1199        let toml_content = "theme = \"monokai\"\n";
1200        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1201
1202        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1203        assert_eq!(merged.theme, "monokai");
1204        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1205    }
1206
1207    #[test]
1208    fn test_load_from_dir_with_project_config() {
1209        let _guard = EnvGuard::new(&[
1210            "OXI_MODEL",
1211            "OXI_PROVIDER",
1212            "OXI_THEME",
1213            "OXI_TOOL_TIMEOUT",
1214            "OXI_TEMPERATURE",
1215            "OXI_MAX_TOKENS",
1216            "OXI_SESSION_DIR",
1217            "OXI_STREAM",
1218            "OXI_EXTENSIONS_ENABLED",
1219        ]);
1220        let tmp = tempfile::tempdir().unwrap();
1221        let oxi_dir = tmp.path().join(".oxi");
1222        fs::create_dir_all(&oxi_dir).unwrap();
1223        let settings_path = oxi_dir.join("settings.toml");
1224        // Write v3 format: default_model contains "provider/model"
1225        fs::write(
1226            &settings_path,
1227            "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1228        )
1229        .unwrap();
1230
1231        let settings = Settings::load_from(tmp.path()).unwrap();
1232        // Migration moves default_model → last_used_model
1233        assert_eq!(
1234            settings.last_used_model,
1235            Some("gemini-2.0-flash".to_string())
1236        );
1237        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1238    }
1239
1240    #[test]
1241    fn test_load_from_dir_no_config() {
1242        // Clean env vars that load_from() reads via apply_env()
1243        let _guard = EnvGuard::new(&[
1244            "OXI_MODEL",
1245            "OXI_PROVIDER",
1246            "OXI_THEME",
1247            "OXI_TOOL_TIMEOUT",
1248            "OXI_TEMPERATURE",
1249            "OXI_MAX_TOKENS",
1250            "OXI_SESSION_DIR",
1251            "OXI_STREAM",
1252            "OXI_EXTENSIONS_ENABLED",
1253        ]);
1254        let tmp = tempfile::tempdir().unwrap();
1255        let settings = Settings::load_from(tmp.path()).unwrap();
1256        // Falls back to defaults (may include global ~/.oxi/settings)
1257        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1258    }
1259
1260    // ── Environment variables ────────────────────────────────────────
1261
1262    #[test]
1263    fn test_from_env() {
1264        // NOTE: Environment variable overrides are disabled.
1265        // from_env() returns defaults only.
1266        let _guard = EnvGuard::new(&[
1267            // no env vars to clear
1268            "OXI_MODEL",
1269            "OXI_THEME",
1270            "OXI_TOOL_TIMEOUT",
1271            "OXI_PROVIDER",
1272            "OXI_DEFAULT_MODEL",
1273        ]);
1274
1275        let settings = Settings::from_env();
1276        // All fields should be at defaults since env overrides are disabled
1277        assert_eq!(settings.last_used_model, None);
1278        assert_eq!(settings.theme, "default");
1279        assert_eq!(settings.tool_timeout_seconds, 120);
1280    }
1281
1282    #[test]
1283    fn test_apply_env_boolish() {
1284        // NOTE: Environment variable overrides are disabled.
1285        // apply_env() is a no-op.
1286        let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1287        unsafe { env::set_var("OXI_STREAM", "false") };
1288        unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1289
1290        let mut settings = Settings::default();
1291        settings.apply_env();
1292        // Since env overrides are disabled, values stay at defaults
1293        assert!(settings.stream_responses); // default is true
1294        assert!(settings.extensions_enabled); // default is true
1295    }
1296
1297    #[test]
1298    fn test_apply_env_temperature() {
1299        // NOTE: Environment variable overrides are disabled.
1300        let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1301        unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1302
1303        let mut settings = Settings::default();
1304        settings.apply_env();
1305        // Since env overrides are disabled, temperature stays at None
1306        assert_eq!(settings.default_temperature, None);
1307    }
1308
1309    #[test]
1310    fn test_env_does_not_override_when_unset() {
1311        let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1312        let settings = Settings::from_env();
1313        assert!(settings.last_used_model.is_none());
1314        assert!(settings.last_used_provider.is_none());
1315    }
1316
1317    #[test]
1318    fn test_parse_thinking_level() {
1319        assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1320        assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1321        assert_eq!(
1322            parse_thinking_level("MINIMAL"),
1323            Some(ThinkingLevel::Minimal)
1324        );
1325        assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1326        assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1327        assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1328        assert_eq!(
1329            parse_thinking_level("Standard"),
1330            Some(ThinkingLevel::Medium)
1331        );
1332        assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1333        assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1334        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1335        assert_eq!(parse_thinking_level("invalid"), None);
1336    }
1337
1338    #[test]
1339    fn test_parse_boolish() {
1340        assert!(parse_boolish("true").unwrap());
1341        assert!(parse_boolish("1").unwrap());
1342        assert!(parse_boolish("yes").unwrap());
1343        assert!(parse_boolish("ON").unwrap());
1344        assert!(!parse_boolish("false").unwrap());
1345        assert!(!parse_boolish("0").unwrap());
1346        assert!(!parse_boolish("no").unwrap());
1347        assert!(!parse_boolish("OFF").unwrap());
1348        assert!(parse_boolish("maybe").is_err());
1349    }
1350
1351    // ── Effective accessors ──────────────────────────────────────────
1352
1353    #[test]
1354    fn test_effective_model_returns_last_used() {
1355        let mut settings = Settings::default();
1356        settings.last_used_model = Some("openai/gpt-4o".to_string());
1357        assert_eq!(
1358            settings.effective_model(None),
1359            Some("openai/gpt-4o".to_string())
1360        );
1361    }
1362
1363    #[test]
1364    fn test_effective_model_cli_overrides() {
1365        let mut settings = Settings::default();
1366        settings.last_used_model = Some("openai/gpt-4o".to_string());
1367        assert_eq!(
1368            settings.effective_model(Some("anthropic/claude-3")),
1369            Some("anthropic/claude-3".to_string())
1370        );
1371    }
1372
1373    #[test]
1374    fn test_effective_model_none_when_unset() {
1375        let settings = Settings::default();
1376        assert_eq!(settings.effective_model(None), None);
1377    }
1378
1379    #[test]
1380    fn test_effective_model_falls_back_to_last_used() {
1381        let mut settings = Settings::default();
1382        settings.last_used_model = Some("anthropic/claude-3".to_string());
1383        assert_eq!(
1384            settings.effective_model(None),
1385            Some("anthropic/claude-3".to_string())
1386        );
1387    }
1388
1389    #[test]
1390    fn test_effective_model_returns_none_when_nothing_set() {
1391        let settings = Settings::default();
1392        assert_eq!(settings.effective_model(None), None);
1393    }
1394
1395    #[test]
1396    fn test_effective_temperature_prefers_f64() {
1397        let mut settings = Settings::default();
1398        settings.temperature = Some(0.5);
1399        settings.default_temperature = Some(0.7);
1400        assert_eq!(settings.effective_temperature(), Some(0.7));
1401    }
1402
1403    #[test]
1404    fn test_effective_temperature_falls_back_to_f32() {
1405        let mut settings = Settings::default();
1406        settings.temperature = Some(0.5);
1407        assert_eq!(settings.effective_temperature(), Some(0.5));
1408    }
1409
1410    #[test]
1411    fn test_effective_max_tokens_prefers_usize() {
1412        let mut settings = Settings::default();
1413        settings.max_tokens = Some(1024);
1414        settings.max_response_tokens = Some(4096);
1415        assert_eq!(settings.effective_max_tokens(), Some(4096));
1416    }
1417
1418    #[test]
1419    fn test_effective_max_tokens_falls_back_to_u32() {
1420        let mut settings = Settings::default();
1421        settings.max_tokens = Some(1024);
1422        assert_eq!(settings.effective_max_tokens(), Some(1024));
1423    }
1424
1425    // ── Session dir ──────────────────────────────────────────────────
1426
1427    #[test]
1428    fn test_effective_session_dir_default() {
1429        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1430        let settings = Settings::default();
1431        let dir = settings.effective_session_dir().unwrap();
1432        assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1433    }
1434
1435    #[test]
1436    fn test_effective_session_dir_from_field() {
1437        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1438        let mut settings = Settings::default();
1439        settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1440        assert_eq!(
1441            settings.effective_session_dir().unwrap(),
1442            PathBuf::from("/tmp/oxi-sessions")
1443        );
1444    }
1445
1446    #[test]
1447    fn test_effective_session_dir_env_disabled() {
1448        // NOTE: Environment variable overrides are disabled.
1449        // OXI_SESSION_DIR is ignored; effective_session_dir() returns the field value (or default).
1450        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1451        unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1452        let settings = Settings::default();
1453        // Env is ignored, so it should use the default path, not /tmp/env-sessions
1454        let dir = settings.effective_session_dir().unwrap();
1455        assert!(
1456            dir.ends_with("sessions"),
1457            "expected default sessions dir, got: {:?}",
1458            dir
1459        );
1460    }
1461
1462    // ── Migration ────────────────────────────────────────────────────
1463
1464    #[test]
1465    fn test_migration_v0_to_v1() {
1466        let mut settings = Settings::default();
1467        settings.version = 0;
1468        settings.tool_timeout_seconds = 0; // v0 might not have this field
1469
1470        let migrated = Settings::migrate(settings).unwrap();
1471        assert_eq!(migrated.version, SETTINGS_VERSION);
1472        assert_eq!(migrated.tool_timeout_seconds, 120);
1473    }
1474
1475    #[test]
1476    fn test_migration_already_current() {
1477        let settings = Settings::default();
1478        let migrated = Settings::migrate(settings).unwrap();
1479        assert_eq!(migrated.version, SETTINGS_VERSION);
1480    }
1481
1482    #[test]
1483    fn test_migration_v3_to_v4_splits_model() {
1484        let mut settings = Settings::default();
1485        settings.version = 3;
1486        settings.default_model = Some("openai/gpt-4o".to_string());
1487        settings.default_provider = None;
1488
1489        let migrated = Settings::migrate(settings).unwrap();
1490        assert_eq!(migrated.version, SETTINGS_VERSION);
1491        assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1492        assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1493    }
1494
1495    #[test]
1496    fn test_migration_v3_no_slash_keeps_model() {
1497        let mut settings = Settings::default();
1498        settings.version = 3;
1499        settings.default_model = Some("bare-model-name".to_string());
1500
1501        let migrated = Settings::migrate(settings).unwrap();
1502        assert_eq!(migrated.version, SETTINGS_VERSION);
1503        assert_eq!(
1504            migrated.last_used_model,
1505            Some("bare-model-name".to_string())
1506        );
1507    }
1508
1509    #[test]
1510    fn test_migration_future_version_fails() {
1511        let mut settings = Settings::default();
1512        settings.version = 9999;
1513        assert!(Settings::migrate(settings).is_err());
1514    }
1515
1516    // ── output_languages tests (TUI language policy, v5) ────────────
1517
1518    #[test]
1519    fn test_default_output_languages_is_empty() {
1520        let settings = Settings::default();
1521        assert!(
1522            settings.output_languages.is_empty(),
1523            "all channels should default to auto (empty map)"
1524        );
1525    }
1526
1527    #[test]
1528    fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1529        let mut settings = Settings::default();
1530        settings.version = 4;
1531        settings
1532            .output_languages
1533            .insert("response".to_string(), "ko".to_string());
1534        settings
1535            .output_languages
1536            .insert("commit_message".to_string(), "en".to_string());
1537
1538        let migrated = Settings::migrate(settings).unwrap();
1539        assert_eq!(migrated.version, SETTINGS_VERSION);
1540        assert_eq!(
1541            migrated.output_languages.get("response"),
1542            Some(&"ko".to_string())
1543        );
1544        assert_eq!(
1545            migrated.output_languages.get("commit_message"),
1546            Some(&"en".to_string())
1547        );
1548    }
1549
1550    #[test]
1551    fn test_migration_v4_to_v5_creates_empty_if_missing() {
1552        // A v4 file loaded fresh will not have `output_languages` at all —
1553        // serde fills it with an empty HashMap via `#[serde(default)]`.
1554        // After migration, version is bumped to 5 with the empty map intact.
1555        let mut settings = Settings::default();
1556        settings.version = 4;
1557        assert!(settings.output_languages.is_empty());
1558
1559        let migrated = Settings::migrate(settings).unwrap();
1560        assert_eq!(migrated.version, SETTINGS_VERSION);
1561        assert!(migrated.output_languages.is_empty());
1562    }
1563
1564    #[test]
1565    fn test_validate_keeps_user_defined_channel() {
1566        // Per the extension-map contract, ANY channel key must be
1567        // accepted (known or user-defined). The validator must NOT
1568        // drop unknown channels — `language_directive` will use the
1569        // raw key as a label fallback.
1570        let mut settings = Settings::default();
1571        settings
1572            .output_languages
1573            .insert("pr_description".to_string(), "en".to_string()); // user-defined
1574        settings
1575            .output_languages
1576            .insert("response".to_string(), "ko".to_string()); // known
1577
1578        settings.validate_output_languages();
1579
1580        assert!(settings.output_languages.contains_key("pr_description"));
1581        assert!(settings.output_languages.contains_key("response"));
1582        assert_eq!(
1583            settings.output_languages.get("pr_description"),
1584            Some(&"en".to_string())
1585        );
1586        assert_eq!(
1587            settings.output_languages.get("response"),
1588            Some(&"ko".to_string())
1589        );
1590    }
1591
1592    #[test]
1593    fn test_validate_keeps_unknown_lang_with_warning() {
1594        let mut settings = Settings::default();
1595        settings
1596            .output_languages
1597            .insert("response".to_string(), "klingon".to_string()); // unknown code
1598        settings
1599            .output_languages
1600            .insert("commit_message".to_string(), "en".to_string()); // known
1601
1602        settings.validate_output_languages();
1603
1604        // Unknown code is KEPT (with a warn log) so users can add languages
1605        // without code changes.
1606        assert_eq!(
1607            settings.output_languages.get("response"),
1608            Some(&"klingon".to_string())
1609        );
1610        assert_eq!(
1611            settings.output_languages.get("commit_message"),
1612            Some(&"en".to_string())
1613        );
1614    }
1615
1616    #[test]
1617    fn test_known_channels_table_includes_core_four() {
1618        let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1619        assert!(keys.contains(&"response"));
1620        assert!(keys.contains(&"code_comment"));
1621        assert!(keys.contains(&"documentation"));
1622        assert!(keys.contains(&"commit_message"));
1623    }
1624
1625    #[test]
1626    fn test_known_langs_table_includes_auto_and_english() {
1627        let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1628        assert!(codes.contains(&"auto"));
1629        assert!(codes.contains(&"en"));
1630    }
1631
1632    #[test]
1633    fn test_save_and_load_roundtrip_preserves_output_languages() {
1634        let tmp = tempfile::tempdir().unwrap();
1635        let settings_path = tmp.path().join("settings.toml");
1636
1637        let mut original = Settings::default();
1638        original
1639            .output_languages
1640            .insert("response".to_string(), "ko".to_string());
1641        original
1642            .output_languages
1643            .insert("commit_message".to_string(), "en".to_string());
1644
1645        let content = toml::to_string_pretty(&original).unwrap();
1646        fs::write(&settings_path, &content).unwrap();
1647
1648        let loaded_content = fs::read_to_string(&settings_path).unwrap();
1649        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1650
1651        assert_eq!(
1652            loaded.output_languages.get("response"),
1653            Some(&"ko".to_string())
1654        );
1655        assert_eq!(
1656            loaded.output_languages.get("commit_message"),
1657            Some(&"en".to_string())
1658        );
1659    }
1660
1661    // ── Persistence ──────────────────────────────────────────────────
1662
1663    #[test]
1664    fn test_save_and_load_roundtrip() {
1665        let tmp = tempfile::tempdir().unwrap();
1666        let settings_path = tmp.path().join("settings.toml");
1667
1668        let mut original = Settings::default();
1669        original.last_used_model = Some("gpt-4o".to_string());
1670        original.last_used_provider = Some("openai".to_string());
1671        original.theme = "dracula".to_string();
1672        original.tool_timeout_seconds = 60;
1673
1674        // Serialize
1675        let content = toml::to_string_pretty(&original).unwrap();
1676        fs::write(&settings_path, &content).unwrap();
1677
1678        // Deserialize
1679        let loaded_content = fs::read_to_string(&settings_path).unwrap();
1680        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1681
1682        assert_eq!(loaded.last_used_model, original.last_used_model);
1683        assert_eq!(loaded.theme, original.theme);
1684        assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1685    }
1686
1687    #[test]
1688    fn test_toml_roundtrip_preserves_new_fields() {
1689        let mut settings = Settings::default();
1690        settings.default_temperature = Some(0.8);
1691        settings.max_response_tokens = Some(8192);
1692        settings.auto_compaction = false;
1693        settings.extensions_enabled = false;
1694        settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1695
1696        let toml_str = toml::to_string_pretty(&settings).unwrap();
1697        let parsed: Settings = toml::from_str(&toml_str).unwrap();
1698
1699        assert_eq!(parsed.default_temperature, Some(0.8));
1700        assert_eq!(parsed.max_response_tokens, Some(8192));
1701        assert!(!parsed.auto_compaction);
1702        assert!(!parsed.extensions_enabled);
1703        assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1704    }
1705
1706    // ── JSON format tests ──────────────────────────────────────────────
1707
1708    #[test]
1709    fn test_json_roundtrip() {
1710        let mut settings = Settings::default();
1711        settings.last_used_model = Some("gpt-4o".to_string());
1712        settings.last_used_provider = Some("openai".to_string());
1713        settings.theme = "dracula".to_string();
1714        settings.tool_timeout_seconds = 60;
1715        settings.default_temperature = Some(0.8);
1716        settings.max_response_tokens = Some(8192);
1717
1718        let json_str = serde_json::to_string_pretty(&settings).unwrap();
1719        let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1720
1721        assert_eq!(parsed.last_used_model, settings.last_used_model);
1722        assert_eq!(parsed.theme, settings.theme);
1723        assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1724        assert_eq!(parsed.default_temperature, settings.default_temperature);
1725        assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1726    }
1727
1728    #[test]
1729    fn test_json_serialize_for_format() {
1730        let mut settings = Settings::default();
1731        settings.last_used_model = Some("claude-3".to_string());
1732        settings.last_used_provider = Some("anthropic".to_string());
1733        settings.thinking_level = ThinkingLevel::Minimal;
1734
1735        let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1736        let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1737
1738        assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1739        assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1740    }
1741
1742    #[test]
1743    fn test_toml_serialize_for_format() {
1744        let mut settings = Settings::default();
1745        settings.last_used_model = Some("gemini-pro".to_string());
1746        settings.last_used_provider = Some("google".to_string());
1747        settings.thinking_level = ThinkingLevel::High;
1748
1749        let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1750        let parsed: Settings = toml::from_str(&toml_content).unwrap();
1751
1752        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1753        assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1754    }
1755
1756    #[test]
1757    fn test_parse_from_str_json() {
1758        let json_content = r#"{
1759            "last_used_model": "gpt-4",
1760            "last_used_provider": "openai",
1761            "theme": "nord",
1762            "tool_timeout_seconds": 90
1763        }"#;
1764
1765        let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1766        assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1767        assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1768        assert_eq!(settings.theme, "nord");
1769        assert_eq!(settings.tool_timeout_seconds, 90);
1770        // Unchanged fields retain defaults
1771        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1772        assert!(settings.extensions_enabled);
1773    }
1774
1775    #[test]
1776    fn test_parse_from_str_toml() {
1777        let toml_content = r#"
1778last_used_model = "claude-opus"
1779last_used_provider = "anthropic"
1780theme = "monokai"
1781tool_timeout_seconds = 45
1782"#;
1783
1784        let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1785        assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1786        assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1787        assert_eq!(settings.theme, "monokai");
1788        assert_eq!(settings.tool_timeout_seconds, 45);
1789        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1790    }
1791
1792    #[test]
1793    fn test_layer_file_json() {
1794        let base = Settings::default();
1795
1796        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1797        let json_content = r#"{
1798            "last_used_model": "gpt-4o",
1799            "last_used_provider": "openai",
1800            "theme": "dracula",
1801            "auto_compaction": false
1802        }"#;
1803        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1804
1805        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1806        assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1807        assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1808        assert_eq!(merged.theme, "dracula");
1809        assert!(!merged.auto_compaction);
1810        // Unchanged fields retain defaults
1811        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1812        assert!(merged.extensions_enabled);
1813        assert_eq!(merged.tool_timeout_seconds, 120);
1814    }
1815
1816    #[test]
1817    fn test_layer_file_json_preserves_unset() {
1818        let mut base = Settings::default();
1819        base.last_used_provider = Some("deepseek".to_string());
1820
1821        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1822        let json_content = r#"{ "theme": "nord" }"#;
1823        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1824
1825        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1826        assert_eq!(merged.theme, "nord");
1827        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1828    }
1829
1830    #[test]
1831    fn test_save_to_json() {
1832        let tmp = tempfile::tempdir().unwrap();
1833        let settings_path = tmp.path().join("settings.json");
1834
1835        let mut settings = Settings::default();
1836        settings.last_used_model = Some("gpt-4o".to_string());
1837        settings.last_used_provider = Some("openai".to_string());
1838        settings.theme = "dracula".to_string();
1839        settings.tool_timeout_seconds = 60;
1840
1841        settings.save_to(&settings_path).unwrap();
1842
1843        // Verify it's valid JSON
1844        let content = fs::read_to_string(&settings_path).unwrap();
1845        let parsed: Settings = serde_json::from_str(&content).unwrap();
1846        assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1847        assert_eq!(parsed.theme, "dracula");
1848        assert_eq!(parsed.tool_timeout_seconds, 60);
1849    }
1850
1851    #[test]
1852    fn test_save_to_toml() {
1853        let tmp = tempfile::tempdir().unwrap();
1854        let settings_path = tmp.path().join("settings.toml");
1855
1856        let mut settings = Settings::default();
1857        settings.last_used_model = Some("gemini-pro".to_string());
1858        settings.last_used_provider = Some("google".to_string());
1859        settings.theme = "monokai".to_string();
1860        settings.tool_timeout_seconds = 90;
1861
1862        settings.save_to(&settings_path).unwrap();
1863
1864        // Verify it's valid TOML
1865        let content = fs::read_to_string(&settings_path).unwrap();
1866        let parsed: Settings = toml::from_str(&content).unwrap();
1867        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1868        assert_eq!(parsed.theme, "monokai");
1869        assert_eq!(parsed.tool_timeout_seconds, 90);
1870    }
1871
1872    #[test]
1873    fn test_load_from_dir_with_json_project_config() {
1874        let _guard = EnvGuard::new(&[
1875            "OXI_MODEL",
1876            "OXI_PROVIDER",
1877            "OXI_THEME",
1878            "OXI_TOOL_TIMEOUT",
1879            "OXI_TEMPERATURE",
1880            "OXI_MAX_TOKENS",
1881            "OXI_SESSION_DIR",
1882            "OXI_STREAM",
1883            "OXI_EXTENSIONS_ENABLED",
1884        ]);
1885        let tmp = tempfile::tempdir().unwrap();
1886        let oxi_dir = tmp.path().join(".oxi");
1887        fs::create_dir_all(&oxi_dir).unwrap();
1888        let settings_path = oxi_dir.join("settings.json");
1889        // v3 format: default_model has provider/model
1890        let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1891        fs::write(&settings_path, json_content).unwrap();
1892
1893        let settings = Settings::load_from(tmp.path()).unwrap();
1894        // Migration splits provider from model
1895        assert_eq!(
1896            settings.last_used_model,
1897            Some("gemini-2.0-flash".to_string())
1898        );
1899        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1900    }
1901
1902    #[test]
1903    fn test_find_project_settings_json_priority() {
1904        let tmp = tempfile::tempdir().unwrap();
1905        let oxi_dir = tmp.path().join(".oxi");
1906        fs::create_dir_all(&oxi_dir).unwrap();
1907
1908        // Create both files
1909        let json_path = oxi_dir.join("settings.json");
1910        let toml_path = oxi_dir.join("settings.toml");
1911        fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1912        fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1913
1914        // JSON takes priority
1915        let found = Settings::find_project_settings(tmp.path());
1916        assert!(found.is_some());
1917        assert_eq!(
1918            found.unwrap().file_name().unwrap().to_str().unwrap(),
1919            "settings.json"
1920        );
1921    }
1922
1923    #[test]
1924    fn test_find_project_settings_json_only() {
1925        let tmp = tempfile::tempdir().unwrap();
1926        let oxi_dir = tmp.path().join(".oxi");
1927        fs::create_dir_all(&oxi_dir).unwrap();
1928
1929        let json_path = oxi_dir.join("settings.json");
1930        fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1931
1932        let found = Settings::find_project_settings(tmp.path());
1933        assert!(found.is_some());
1934        assert_eq!(
1935            found.unwrap().file_name().unwrap().to_str().unwrap(),
1936            "settings.json"
1937        );
1938    }
1939
1940    #[test]
1941    fn test_find_project_settings_toml_fallback() {
1942        let tmp = tempfile::tempdir().unwrap();
1943        let oxi_dir = tmp.path().join(".oxi");
1944        fs::create_dir_all(&oxi_dir).unwrap();
1945
1946        let toml_path = oxi_dir.join("settings.toml");
1947        fs::write(&toml_path, r#"theme = "test""#).unwrap();
1948
1949        let found = Settings::find_project_settings(tmp.path());
1950        assert!(found.is_some());
1951        assert_eq!(
1952            found.unwrap().file_name().unwrap().to_str().unwrap(),
1953            "settings.toml"
1954        );
1955    }
1956
1957    #[test]
1958    fn test_detect_format() {
1959        let json_path = PathBuf::from("/test/settings.json");
1960        let toml_path = PathBuf::from("/test/settings.toml");
1961        let unknown_path = PathBuf::from("/test/settings");
1962
1963        assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1964        assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1965        assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1966        // Default
1967    }
1968
1969    #[test]
1970    fn test_settings_format_extension() {
1971        assert_eq!(SettingsFormat::Json.extension(), "json");
1972        assert_eq!(SettingsFormat::Toml.extension(), "toml");
1973    }
1974
1975    #[test]
1976    fn test_layer_json_over_toml() {
1977        // Test that when loading, JSON takes priority over TOML
1978        let tmp = tempfile::tempdir().unwrap();
1979        let oxi_dir = tmp.path().join(".oxi");
1980        fs::create_dir_all(&oxi_dir).unwrap();
1981
1982        let json_path = oxi_dir.join("settings.json");
1983        let toml_path = oxi_dir.join("settings.toml");
1984
1985        // JSON has model set to "json-model"
1986        fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1987        // TOML has model set to "toml-model"
1988        fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1989
1990        // JSON takes priority
1991        let settings = Settings::load_from(tmp.path()).unwrap();
1992        assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1993    }
1994
1995    #[test]
1996    fn test_mixed_format_loading() {
1997        // Test loading a TOML file through the generic layer_file
1998        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1999        let toml_content = r#"
2000last_used_model = "loaded-via-toml"
2001theme = "loaded-theme"
2002stream_responses = false
2003"#;
2004        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2005
2006        let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2007        assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2008        assert_eq!(merged.theme, "loaded-theme");
2009        assert!(!merged.stream_responses);
2010    }
2011
2012    #[test]
2013    fn test_merge_json_values() {
2014        let base = serde_json::json!({
2015            "version": 1,
2016            "theme": "default",
2017            "extensions": ["ext1"],
2018            "nested": {
2019                "a": 1,
2020                "b": 2
2021            }
2022        });
2023
2024        let override_ = serde_json::json!({
2025            "version": 2,
2026            "theme": "dark",
2027            "extensions": ["ext2"],
2028            "nested": {
2029                "b": 20,
2030                "c": 30
2031            }
2032        });
2033
2034        let merged = merge_json_values(base, override_);
2035
2036        assert_eq!(merged["version"], 2);
2037        assert_eq!(merged["theme"], "dark");
2038        // Arrays are replaced, not merged
2039        assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2040        // Nested objects are deeply merged
2041        assert_eq!(merged["nested"]["a"], 1);
2042        assert_eq!(merged["nested"]["b"], 20);
2043        assert_eq!(merged["nested"]["c"], 30);
2044    }
2045
2046    #[test]
2047    fn test_save_project_preserves_existing_format() {
2048        let tmp = tempfile::tempdir().unwrap();
2049        let oxi_dir = tmp.path().join(".oxi");
2050        fs::create_dir_all(&oxi_dir).unwrap();
2051
2052        // Create existing TOML file
2053        let toml_path = oxi_dir.join("settings.toml");
2054        fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2055
2056        let mut settings = Settings::default();
2057        settings.theme = "new-theme".to_string();
2058        settings.save_project(tmp.path()).unwrap();
2059
2060        // Should still be TOML
2061        let content = fs::read_to_string(&toml_path).unwrap();
2062        assert!(content.contains("new-theme"));
2063        assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2064    }
2065
2066    #[test]
2067    fn test_save_project_creates_json_by_default() {
2068        let tmp = tempfile::tempdir().unwrap();
2069        let oxi_dir = tmp.path().join(".oxi");
2070        fs::create_dir_all(&oxi_dir).unwrap();
2071        // Don't create any settings file
2072
2073        let mut settings = Settings::default();
2074        settings.theme = "json-theme".to_string();
2075        settings.save_project(tmp.path()).unwrap();
2076
2077        // Should create JSON file
2078        let json_path = oxi_dir.join("settings.json");
2079        assert!(json_path.exists());
2080        let content = fs::read_to_string(&json_path).unwrap();
2081        assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2082        assert!(content.contains("json-theme"));
2083    }
2084
2085    // ── Custom provider tests ───────────────────────────────────────
2086
2087    #[test]
2088    fn test_custom_provider_default_api() {
2089        use super::CustomProvider;
2090        let cp = CustomProvider {
2091            name: "test".to_string(),
2092            base_url: "https://api.test.com/v1".to_string(),
2093            api_key_env: "TEST_API_KEY".to_string(),
2094            api: super::default_custom_provider_api(),
2095        };
2096        assert_eq!(cp.api, "openai-completions");
2097    }
2098
2099    #[test]
2100    fn test_custom_provider_toml_deserialize() {
2101        let toml_content = r#"
2102[[custom_providers]]
2103name = "minimax"
2104base_url = "https://api.minimax.chat/v1"
2105api_key_env = "MINIMAX_API_KEY"
2106api = "openai-completions"
2107
2108[[custom_providers]]
2109name = "zai"
2110base_url = "https://api.z.ai/v1"
2111api_key_env = "ZAI_API_KEY"
2112api = "openai-responses"
2113"#;
2114        let settings: Settings = toml::from_str(toml_content).unwrap();
2115        assert_eq!(settings.custom_providers.len(), 2);
2116        assert_eq!(settings.custom_providers[0].name, "minimax");
2117        assert_eq!(
2118            settings.custom_providers[0].base_url,
2119            "https://api.minimax.chat/v1"
2120        );
2121        assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2122        assert_eq!(settings.custom_providers[0].api, "openai-completions");
2123        assert_eq!(settings.custom_providers[1].name, "zai");
2124        assert_eq!(settings.custom_providers[1].api, "openai-responses");
2125    }
2126
2127    #[test]
2128    fn test_custom_provider_json_deserialize() {
2129        let json_content = r#"{
2130            "custom_providers": [
2131                {
2132                    "name": "minimax",
2133                    "base_url": "https://api.minimax.chat/v1",
2134                    "api_key_env": "MINIMAX_API_KEY",
2135                    "api": "openai-completions"
2136                }
2137            ]
2138        }"#;
2139        let settings: Settings = serde_json::from_str(json_content).unwrap();
2140        assert_eq!(settings.custom_providers.len(), 1);
2141        assert_eq!(settings.custom_providers[0].name, "minimax");
2142    }
2143
2144    #[test]
2145    fn test_custom_provider_toml_roundtrip() {
2146        let mut settings = Settings::default();
2147        settings.custom_providers.push(super::CustomProvider {
2148            name: "test".to_string(),
2149            base_url: "https://api.test.com/v1".to_string(),
2150            api_key_env: "TEST_API_KEY".to_string(),
2151            api: "openai-completions".to_string(),
2152        });
2153
2154        let toml_str = toml::to_string_pretty(&settings).unwrap();
2155        let parsed: Settings = toml::from_str(&toml_str).unwrap();
2156        assert_eq!(parsed.custom_providers.len(), 1);
2157        assert_eq!(parsed.custom_providers[0].name, "test");
2158        assert_eq!(
2159            parsed.custom_providers[0].base_url,
2160            "https://api.test.com/v1"
2161        );
2162    }
2163
2164    #[test]
2165    fn test_custom_provider_defaults_empty() {
2166        let settings = Settings::default();
2167        assert!(settings.custom_providers.is_empty());
2168    }
2169
2170    #[test]
2171    fn test_custom_provider_layer_file() {
2172        let base = Settings::default();
2173
2174        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2175        let toml_content = r#"
2176[[custom_providers]]
2177name = "my-provider"
2178base_url = "https://api.my-provider.com/v1"
2179api_key_env = "MY_PROVIDER_API_KEY"
2180"#;
2181        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2182
2183        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2184        assert_eq!(merged.custom_providers.len(), 1);
2185        assert_eq!(merged.custom_providers[0].name, "my-provider");
2186        // Default api value
2187        assert_eq!(merged.custom_providers[0].api, "openai-completions");
2188    }
2189}