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