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