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