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