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    /// Backend selector for the autonomous memory pipeline (omp
398    /// `local-backend` analog). `None` (default) disables the
399    /// pipeline; `Some("local")` enables per-session extraction
400    /// + cross-session consolidation.
401    #[serde(default)]
402    pub memory_backend: Option<String>,
403
404    /// LLM fact extraction for `memory_retain`. When `true`, the
405    /// `memory_retain` tool is wrapped so each `put(content, …)`
406    /// call is sent through the configured model's
407    /// structured-extraction prompt and the resulting atomic
408    /// facts are stored individually.
409    #[serde(default = "default_false")]
410    pub memory_llm_extract: bool,
411
412    /// Model pattern used for LLM fact extraction when
413    /// `memory_llm_extract = true`. Empty means "use the
414    /// session's default model".
415    #[serde(default)]
416    pub memory_llm_extract_model: String,
417
418    /// Embedding provider for the Mnemopi engine. One of:
419    /// - `"none"` (default) — recall runs in FTS5-only mode.
420    /// - `"remote"` — OpenAI-compatible `/v1/embeddings`.
421    #[serde(default = "default_embedding_provider")]
422    pub embedding_provider: String,
423
424    /// Base URL for the remote embedding provider (no trailing slash).
425    #[serde(default)]
426    pub embedding_base_url: Option<String>,
427
428    /// Env-var holding the remote-embedding API key (so the key
429    /// never lands in `settings.toml`). Default `OPENAI_API_KEY`.
430    #[serde(default = "default_embedding_api_key_env")]
431    pub embedding_api_key_env: String,
432
433    /// Logical embedding model name. Recorded alongside stored
434    /// embeddings for cache keying and diagnostics.
435    #[serde(default = "default_embedding_model")]
436    pub embedding_model: String,
437
438    // ── TTSR (③) ─────────────────────────────────────────────────────
439    /// Enable Time-Traveling Stream Rules (stream interrupt on rule violation).
440    /// Default: false (opt-in, stable-first).
441    #[serde(default = "default_false")]
442    pub ttsr_enabled: bool,
443
444    /// TTSR interrupt mode. Default: "prose_only".
445    #[serde(default = "default_ttsr_mode")]
446    pub ttsr_interrupt_mode: String,
447
448    // ── Model roles (ported from omp) ────────────────────────────────
449    /// Named model-role → model-pattern assignments (e.g. `"commit"` →
450    /// `"anthropic/claude-haiku"`, `"slow"` → `"pi/default"`).
451    ///
452    /// Empty by default. Role names are open-ended: the 10 built-in roles
453    /// (`default`/`smol`/`slow`/`vision`/`plan`/`designer`/`commit`/`title`/
454    /// `task`/`advisor`) plus any user-defined role are accepted.
455    /// Resolution — including `pi/<role>` alias expansion with cycle
456    /// detection — is done by [`oxi_ai::RoleRegistry`]. The role-switching
457    /// layer (which role is active when) is wired separately.
458    #[serde(default)]
459    pub model_roles: HashMap<String, String>,
460    // ── Advisor (read-only reviewer shadowing the primary agent) ────
461    /// Advisor subsystem settings. Default OFF (opt-in). Drives the
462    /// `oxi_agent::advisor` engine wired into `AgentSession`.
463    #[serde(default)]
464    pub advisor: AdvisorSettings,
465}
466
467/// Advisor subsystem settings — a read-only reviewer that shadows the primary
468/// agent and surfaces advice (`nit`/`concern`/`blocker`). All default OFF;
469/// the advisor is opt-in (set `enabled = true` in `[advisor]`).
470///
471/// Ported from omp's `advisor.*` settings (advisor.enabled /
472/// advisor.syncBacklog / advisor.immuneTurns).
473#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct AdvisorSettings {
475    /// Master switch. Default OFF.
476    #[serde(default = "default_false")]
477    pub enabled: bool,
478    /// Sync-backlog barrier: pause the primary when the advisor falls this many
479    /// turns behind, or `"off"` to never block. omp `advisor.syncBacklog`.
480    /// Default `"off"`.
481    #[serde(default = "default_advisor_sync_backlog")]
482    pub sync_backlog: String,
483    /// Post-interrupt immune-turn cooldown: after a `concern`/`blocker` steers
484    /// in, downgrade further `concern`/`blocker` notes to asides for this many
485    /// turns (prevents advice storms). omp `advisor.immuneTurns`. Default 0.
486    #[serde(default)]
487    pub immune_turns: u64,
488}
489
490impl Default for AdvisorSettings {
491    fn default() -> Self {
492        Self {
493            enabled: false,
494            sync_backlog: default_advisor_sync_backlog(),
495            immune_turns: 0,
496        }
497    }
498}
499
500fn default_advisor_sync_backlog() -> String {
501    "off".to_string()
502}
503
504fn default_theme() -> String {
505    "default".to_string()
506}
507
508fn default_thinking_level() -> ThinkingLevel {
509    ThinkingLevel::Medium
510}
511
512fn default_session_history_size() -> usize {
513    100
514}
515
516fn default_true() -> bool {
517    true
518}
519
520fn default_false() -> bool {
521    false
522}
523
524fn default_ttsr_mode() -> String {
525    "prose_only".to_string()
526}
527
528fn default_circuit_failure_threshold() -> u32 {
529    5
530}
531
532fn default_circuit_open_duration_secs() -> u64 {
533    30
534}
535
536fn default_tool_timeout() -> u64 {
537    120
538}
539
540fn default_memory_backend() -> Option<String> {
541    None
542}
543
544fn default_embedding_provider() -> String {
545    "none".to_string()
546}
547
548fn default_embedding_model() -> String {
549    String::new()
550}
551
552fn default_embedding_api_key_env() -> String {
553    "OPENAI_API_KEY".to_string()
554}
555
556impl Default for Settings {
557    fn default() -> Self {
558        Self {
559            version: SETTINGS_VERSION,
560            thinking_level: ThinkingLevel::Medium,
561            theme: default_theme(),
562            glyph_set: GlyphSet::default(),
563            last_used_model: None,
564            last_used_provider: None,
565            default_model: None,
566            default_provider: None,
567            max_tokens: None,
568            temperature: None,
569            default_temperature: None,
570            max_response_tokens: None,
571            session_history_size: default_session_history_size(),
572            session_dir: None,
573            stream_responses: true,
574            extensions_enabled: true,
575            auto_compaction: true,
576            disabled_tools: Vec::new(),
577            tool_timeout_seconds: default_tool_timeout(),
578            ask_timeout_secs: 0,
579            extensions: Vec::new(),
580            skills: Vec::new(),
581            prompts: Vec::new(),
582            themes: Vec::new(),
583            custom_providers: Vec::new(),
584            dynamic_models: HashMap::new(),
585            // Multi-provider routing defaults
586            enable_routing: false,
587            router_profile: None,
588            prefer_cost_efficient: true,
589            fallback_chain: Vec::new(),
590            enable_fallback: true,
591            disable_fallback: false,
592            circuit_breaker_failure_threshold: 5,
593            circuit_breaker_open_duration_secs: 30,
594            keybindings: HashMap::new(),
595            output_languages: HashMap::new(),
596            language_policy_enabled: false,
597            edit_format: EditFormat::default(),
598            memory_enabled: false,
599            memory_db_path: None,
600            mnemopi_engine: false,
601            memory_backend: default_memory_backend(),
602            memory_llm_extract: false,
603            memory_llm_extract_model: String::new(),
604            embedding_provider: default_embedding_provider(),
605            embedding_base_url: None,
606            embedding_api_key_env: default_embedding_api_key_env(),
607            embedding_model: default_embedding_model(),
608            ttsr_enabled: false,
609            ttsr_interrupt_mode: default_ttsr_mode(),
610            model_roles: HashMap::new(),
611            advisor: AdvisorSettings::default(),
612        }
613    }
614}
615
616impl Settings {
617    // ── Paths ────────────────────────────────────────────────────────
618
619    /// Get the global settings directory path (`~/.oxi`).
620    pub fn settings_dir() -> Result<PathBuf> {
621        let base = dirs::home_dir().context("Cannot determine home directory")?;
622        Ok(base.join(".oxi"))
623    }
624
625    /// Get the global settings TOML file path (`~/.oxi/settings.toml`).
626    pub fn settings_toml_path() -> Result<PathBuf> {
627        Ok(Self::settings_dir()?.join("settings.toml"))
628    }
629
630    /// Get the global settings JSON file path (`~/.oxi/settings.json`).
631    pub fn settings_json_path() -> Result<PathBuf> {
632        Ok(Self::settings_dir()?.join("settings.json"))
633    }
634
635    /// Get the global settings file path (JSON takes priority).
636    ///
637    /// Returns the path to the settings file that should be used.
638    /// If both JSON and TOML exist, JSON is returned (takes priority).
639    /// If only one exists, that path is returned.
640    /// If neither exists, returns the JSON path by default.
641    pub fn settings_path() -> Result<PathBuf> {
642        let json_path = Self::settings_json_path()?;
643        let toml_path = Self::settings_toml_path()?;
644
645        if json_path.exists() && toml_path.exists() {
646            // Both exist: JSON takes priority
647            tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
648            return Ok(json_path);
649        }
650
651        if json_path.exists() {
652            return Ok(json_path);
653        }
654
655        if toml_path.exists() {
656            return Ok(toml_path);
657        }
658
659        // Neither exists: default to JSON
660        Ok(json_path)
661    }
662
663    /// Get the effective settings file path, preferring the specified format.
664    ///
665    /// If `prefer_json` is true, checks JSON first; otherwise checks TOML first.
666    /// Returns the first existing file, or the preferred path if neither exists.
667    pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
668        let json_path = Self::settings_json_path()?;
669        let toml_path = Self::settings_toml_path()?;
670
671        let (primary, secondary) = if prefer_json {
672            (&json_path, &toml_path)
673        } else {
674            (&toml_path, &json_path)
675        };
676
677        if primary.exists() {
678            return Ok(primary.clone());
679        }
680
681        if secondary.exists() {
682            return Ok(secondary.clone());
683        }
684
685        // Neither exists: return preferred path
686        Ok(primary.clone())
687    }
688
689    /// Detect the settings file format from its path.
690    pub fn detect_format(path: &Path) -> SettingsFormat {
691        match path.extension().and_then(|e| e.to_str()) {
692            Some("json") => SettingsFormat::Json,
693            Some("toml") => SettingsFormat::Toml,
694            _ => SettingsFormat::Json, // Default to JSON for unknown extensions
695        }
696    }
697
698    /// Get the project-local settings file path.
699    ///
700    /// Searches for `.oxi/settings.json` first, then `.oxi/settings.toml`.
701    /// Returns the first one found, or None if neither exists.
702    pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
703        let mut dir = start_dir.to_path_buf();
704        loop {
705            // Check JSON first (priority), then TOML
706            let json_candidate = dir.join(".oxi").join("settings.json");
707            if json_candidate.exists() {
708                return Some(json_candidate);
709            }
710
711            let toml_candidate = dir.join(".oxi").join("settings.toml");
712            if toml_candidate.exists() {
713                return Some(toml_candidate);
714            }
715
716            if !dir.pop() {
717                return None;
718            }
719        }
720    }
721
722    /// Resolve the effective session directory.
723    ///
724    /// Priority: `session_dir` field → `~/.oxi/sessions`.
725    pub fn effective_session_dir(&self) -> Result<PathBuf> {
726        if let Some(ref dir) = self.session_dir {
727            return Ok(dir.clone());
728        }
729        Ok(Self::settings_dir()?.join("sessions"))
730    }
731
732    // ── Loading ──────────────────────────────────────────────────────
733
734    /// Load settings, applying all layers:
735    ///
736    /// 1. Built-in defaults
737    /// 2. Global `~/.oxi/settings.toml`
738    /// 3. Project `.oxi/settings.toml`
739    /// 4. Environment variable overrides
740    ///
741    /// # Examples
742    ///
743    /// ```ignore
744    /// use oxi_cli::Settings;
745    ///
746    /// let settings = Settings::load().expect("Failed to load settings");
747    /// println!("Using model: {}", settings.effective_model(None));
748    /// ```
749    pub fn load() -> Result<Self> {
750        Self::load_from_cwd()
751    }
752
753    /// Load settings with an explicit working directory for project config discovery.
754    ///
755    /// Always layers the global config from `Self::settings_path()` when it
756    /// exists. Use [`Settings::load_from_with`] to inject a custom global
757    /// path (e.g. for tests or portable mode).
758    pub fn load_from(dir: &std::path::Path) -> Result<Self> {
759        Self::load_from_with(dir, None)
760    }
761
762    /// Load settings with an explicit project directory and an optional
763    /// global settings path override.
764    ///
765    /// Layering order:
766    /// 1. Defaults
767    /// 2. Global config from `global_override` if `Some`, else from
768    ///    `Self::settings_path()` if it exists.
769    /// 3. Project config (`<dir>/.oxi/settings.{toml,json}`).
770    /// 4. Environment variable overrides.
771    /// 5. Migration.
772    /// 6. TUI language policy validation.
773    ///
774    /// Passing `global_override = None` keeps the default behavior of
775    /// reading the user's real `~/.oxi/settings.{toml,json}`. Tests pass
776    /// `Some(custom_path)` or rely on the real path being absent to get
777    /// pure defaults. (The test suite uses `Some(specific_path)` semantics
778    /// by passing a temp path; passing `None` is also valid for "skip the
779    /// global layer entirely".)
780    pub fn load_from_with(
781        dir: &std::path::Path,
782        global_override: Option<&std::path::Path>,
783    ) -> Result<Self> {
784        // 1. Start from defaults
785        let mut settings = Settings::default();
786
787        // 2. Layer global config (override takes precedence; None = use real
788        //    `~/.oxi/settings.*` if present)
789        let resolved_global: Option<std::path::PathBuf> = match global_override {
790            Some(p) => Some(p.to_path_buf()),
791            None => Self::settings_path().ok(),
792        };
793        if let Some(ref gp) = resolved_global
794            && gp.exists()
795        {
796            settings = Self::layer_file(&settings, gp)?;
797        }
798
799        // 3. Layer project config
800        if let Some(project_path) = Self::find_project_settings(dir) {
801            settings = Self::layer_file(&settings, &project_path)?;
802        }
803
804        // 4. Layer environment variables
805        settings.apply_env();
806
807        // 5. Run migration if needed
808        settings = Self::migrate(settings)?;
809
810        // 6. Validate TUI-specific language policy
811        settings.validate_output_languages();
812
813        Ok(settings)
814    }
815
816    /// Warn on unknown `output_languages` language codes. Channel
817    /// keys are **not** validated: any channel (known or user-defined)
818    /// is accepted so users can add new channels in `settings.toml`
819    /// without code changes. `KNOWN_CHANNELS` provides a label table
820    /// for the prompt directive; unknown channels fall back to using
821    /// the raw key as the label.
822    fn validate_output_languages(&mut self) {
823        if self.output_languages.is_empty() {
824            return;
825        }
826        let known_langs: std::collections::HashSet<&str> =
827            KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
828
829        for (channel, lang) in &self.output_languages {
830            if !known_langs.contains(lang.as_str()) {
831                tracing::warn!(
832                    "Unknown output_languages language code '{}' for channel '{}'. \
833                     Keeping as-is (the model will likely understand).",
834                    lang,
835                    channel
836                );
837            }
838        }
839    }
840
841    /// Convenience: load from current working directory.
842    pub fn load_from_cwd() -> Result<Self> {
843        let cwd = env::current_dir().context("Cannot determine current directory")?;
844        Self::load_from(&cwd)
845    }
846
847    /// Parse a settings file (TOML or JSON) and overlay its values onto `base`.
848    ///
849    /// The format is auto-detected based on the file extension.
850    /// Fields present in the file replace those in `base`; absent fields
851    /// are left untouched.
852    fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
853        let content = fs::read_to_string(path)
854            .with_context(|| format!("Failed to read settings from {}", path.display()))?;
855
856        let format = Self::detect_format(path);
857        let overlay: serde_json::Value = match format {
858            SettingsFormat::Toml => {
859                let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
860                    format!("Failed to parse TOML settings from {}", path.display())
861                })?;
862                // Convert TOML to JSON Value for uniform merging
863                toml_value_to_json(toml_value)
864            }
865            SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
866                format!("Failed to parse JSON settings from {}", path.display())
867            })?,
868        };
869
870        // Re-serialize the base to JSON, merge with the overlay, then
871        // deserialize back. This gives correct "only override what's
872        // present" semantics.
873        let base_json =
874            serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
875
876        let merged = merge_json_values(base_json, overlay);
877        let result: Settings =
878            serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
879
880        Ok(result)
881    }
882
883    // ── Environment variables ────────────────────────────────────────
884
885    /// Apply environment variable overrides in-place.
886    ///
887    /// DEPRECATED: Environment variable overrides are being phased out in favor
888    /// of file-based configuration (`~/.oxi/settings.toml`). This method is
889    /// kept for CI/CD compatibility but should not be relied upon for local
890    /// development. Use `oxi config set` or `oxi setup` instead.
891    ///
892    /// Supported variables (CI/CD only):
893    ///
894    /// | Env var                    | Setting                |
895    /// |---------------------------|------------------------|
896    /// | `OXI_MODEL`               | `default_model`        |
897    /// | `OXI_PROVIDER`            | `default_provider`     |
898    /// | `OXI_THINKING`            | `thinking_level`       |
899    /// | `OXI_THEME`               | `theme`                |
900    /// | `OXI_MAX_TOKENS`          | `max_tokens`           |
901    /// | `OXI_TEMPERATURE`         | `default_temperature`  |
902    /// | `OXI_SESSION_DIR`         | `session_dir`          |
903    /// | `OXI_STREAM`              | `stream_responses`     |
904    /// | `OXI_EXTENSIONS_ENABLED`  | `extensions_enabled`   |
905    /// | `OXI_AUTO_COMPACTION`     | `auto_compaction`      |
906    /// | `OXI_TOOL_TIMEOUT`        | `tool_timeout_seconds` |
907    /// | `OXI_DISABLED_TOOLS`      | `disabled_tools`       |
908    #[allow(dead_code)]
909    pub fn apply_env(&mut self) {
910        // No-op: environment variable overrides are disabled.
911        // All configuration should come from settings.toml / settings.json.
912        // This method is kept for backward compatibility but does nothing.
913    }
914
915    /// Build a `Settings` instance from **only** environment variables
916    /// (all other fields stay at defaults).
917    ///
918    /// DEPRECATED: Returns defaults since env overrides are disabled.
919    /// Use `Settings::load()` to load from settings.toml instead.
920    #[allow(dead_code)]
921    pub fn from_env() -> Self {
922        Self::default()
923    }
924
925    // ── Persistence ──────────────────────────────────────────────────
926
927    /// Save settings to the global config file.
928    ///
929    /// Uses the format of the existing file if present, otherwise saves as JSON.
930    /// Preserves backward compatibility with existing TOML files.
931    pub fn save(&self) -> Result<()> {
932        let dir = Self::settings_dir()?;
933        let path = Self::settings_path()?;
934
935        if !dir.exists() {
936            fs::create_dir_all(&dir).with_context(|| {
937                format!("Failed to create settings directory {}", dir.display())
938            })?;
939        }
940
941        let format = Self::detect_format(&path);
942        let content = Self::serialize_for_format(self, format)?;
943
944        // Atomic write: write to temp file first, then rename
945        let tmp_path = path.with_extension("tmp");
946        fs::write(&tmp_path, &content)
947            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
948        fs::rename(&tmp_path, &path)
949            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
950
951        Ok(())
952    }
953
954    /// Save settings to a specific path, using the format determined by the file extension.
955    pub fn save_to(&self, path: &Path) -> Result<()> {
956        if let Some(parent) = path.parent()
957            && !parent.exists()
958        {
959            fs::create_dir_all(parent)
960                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
961        }
962
963        let format = Self::detect_format(path);
964        let content = Self::serialize_for_format(self, format)?;
965
966        // Atomic write
967        let tmp_path = path.with_extension("tmp");
968        fs::write(&tmp_path, &content)
969            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
970        fs::rename(&tmp_path, path)
971            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
972
973        Ok(())
974    }
975
976    /// Save settings to the project-local config file.
977    ///
978    /// Uses the format of the existing file if present, otherwise saves as JSON.
979    pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
980        let dir = project_dir.join(".oxi");
981
982        if !dir.exists() {
983            fs::create_dir_all(&dir).with_context(|| {
984                format!(
985                    "Failed to create project settings directory {}",
986                    dir.display()
987                )
988            })?;
989        }
990
991        // Check if a settings file already exists in project
992        let json_path = dir.join("settings.json");
993        let toml_path = dir.join("settings.toml");
994
995        let path = if json_path.exists() {
996            &json_path
997        } else if toml_path.exists() {
998            &toml_path
999        } else {
1000            // Default to JSON for new files
1001            &json_path
1002        };
1003
1004        let format = Self::detect_format(path);
1005        let content = Self::serialize_for_format(self, format)?;
1006
1007        // Atomic write
1008        let tmp_path = path.with_extension("tmp");
1009        fs::write(&tmp_path, &content)
1010            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
1011        fs::rename(&tmp_path, path)
1012            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
1013
1014        Ok(())
1015    }
1016
1017    /// Serialize settings to a string in the specified format.
1018    pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
1019        match format {
1020            SettingsFormat::Toml => {
1021                toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
1022            }
1023            SettingsFormat::Json => serde_json::to_string_pretty(settings)
1024                .context("Failed to serialize settings to JSON"),
1025        }
1026    }
1027
1028    /// Parse settings from a string in the specified format.
1029    pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
1030        match format {
1031            SettingsFormat::Toml => {
1032                toml::from_str(content).context("Failed to parse TOML settings")
1033            }
1034            SettingsFormat::Json => {
1035                serde_json::from_str(content).context("Failed to parse JSON settings")
1036            }
1037        }
1038    }
1039
1040    // ── CLI overrides ────────────────────────────────────────────────
1041
1042    /// Merge with CLI arguments (CLI takes precedence).
1043    ///
1044    /// # Arguments
1045    ///
1046    /// * `model` — CLI-specified model override
1047    /// * `provider` — CLI-specified provider override
1048    /// * `enable_routing` — CLI-specified enable_routing override
1049    /// * `prefer_cost_efficient` — CLI-specified prefer_cost_efficient override
1050    /// * `fallback_chain` — CLI-specified fallback chain override
1051    /// * `disable_fallback` — CLI-specified disable_fallback override
1052    pub fn merge_cli(
1053        &mut self,
1054        model: Option<String>,
1055        provider: Option<String>,
1056        enable_routing: Option<bool>,
1057        prefer_cost_efficient: Option<bool>,
1058        fallback_chain: Option<Vec<String>>,
1059        disable_fallback: Option<bool>,
1060    ) {
1061        if let Some(m) = model {
1062            self.last_used_model = Some(m);
1063        }
1064        if let Some(p) = provider {
1065            self.last_used_provider = Some(p);
1066        }
1067        if let Some(r) = enable_routing {
1068            self.enable_routing = r;
1069        }
1070        if let Some(p) = prefer_cost_efficient {
1071            self.prefer_cost_efficient = p;
1072        }
1073        if let Some(fc) = fallback_chain
1074            && !fc.is_empty()
1075        {
1076            self.fallback_chain = fc;
1077        }
1078        if let Some(df) = disable_fallback {
1079            self.disable_fallback = df;
1080            // If disable_fallback is true, disable fallback
1081            if df {
1082                self.enable_fallback = false;
1083            }
1084        }
1085    }
1086
1087    /// Get the effective model ID (provider/model format).
1088    /// Returns None if no model is configured.
1089    pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
1090        cli_model.map(String::from).or_else(|| {
1091            // Reconstruct full model ID from separate fields.
1092            // Handles both cases:
1093            //   - last_used_model = "anthropic/claude-sonnet-4" (full ID, stored by save_last_used)
1094            //   - last_used_model = "claude-sonnet-4" + last_used_provider = "anthropic" (split)
1095            let model = self.last_used_model.as_ref()?;
1096            if model.contains('/') {
1097                // Already a full model ID
1098                Some(model.clone())
1099            } else if let Some(ref provider) = self.last_used_provider {
1100                // Reconstruct from separate fields
1101                Some(format!("{}/{}", provider, model))
1102            } else {
1103                Some(model.clone())
1104            }
1105        })
1106    }
1107
1108    /// Get the effective provider.
1109    /// Returns None if no provider is configured.
1110    pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
1111        cli_provider
1112            .map(String::from)
1113            .or_else(|| self.last_used_provider.clone())
1114    }
1115
1116    /// Get the effective temperature, preferring `default_temperature` (f64)
1117    /// over `temperature` (f32), falling back to `None`.
1118    pub fn effective_temperature(&self) -> Option<f64> {
1119        self.default_temperature
1120            .or(self.temperature.map(|t| t as f64))
1121    }
1122
1123    /// Get the effective max tokens, preferring `max_response_tokens` (usize)
1124    /// over `max_tokens` (u32), falling back to `None`.
1125    pub fn effective_max_tokens(&self) -> Option<usize> {
1126        self.max_response_tokens
1127            .or(self.max_tokens.map(|t| t as usize))
1128    }
1129
1130    /// Get the configured router profile name.
1131    pub fn router_profile(&self) -> Option<&str> {
1132        self.router_profile.as_deref()
1133    }
1134
1135    // ── Theme persistence ─────────────────────────────────────────────
1136
1137    /// Save the last used model/provider and persist to disk.
1138    ///
1139    /// Splits the model_id on first `/` to store provider and model separately.
1140    pub fn save_last_used(model_id: &str) {
1141        if let Ok(mut settings) = Self::load() {
1142            if let Some((provider, model)) = model_id.split_once('/') {
1143                settings.last_used_provider = Some(provider.to_string());
1144                settings.last_used_model = Some(model.to_string());
1145            } else {
1146                settings.last_used_model = Some(model_id.to_string());
1147            }
1148            let _ = settings.save();
1149        }
1150    }
1151
1152    /// Save the current theme to settings and persist to disk.
1153    pub fn save_theme(&mut self, name: &str) -> Result<()> {
1154        self.theme = name.to_string();
1155        self.save()
1156    }
1157
1158    /// Get the theme name from settings, returning a default if not set.
1159    pub fn get_theme_name(&self) -> String {
1160        if self.theme.is_empty() || self.theme == "default" {
1161            "oxi_dark".to_string()
1162        } else {
1163            self.theme.clone()
1164        }
1165    }
1166
1167    // ── Migration ────────────────────────────────────────────────────
1168
1169    /// Migrate settings from an older format version to the current one.
1170    ///
1171    /// Currently handles:
1172    /// - Version 0 → Version 6 (multi-step)
1173    /// - Version 1 → Version 6 (multi-step)
1174    /// - Version 2 → Version 6 (multi-step)
1175    /// - Version 3 → Version 4 (default_model → last_used_model)
1176    /// - Version 4 → Version 5 (output_languages field added — no
1177    ///   value migration, serde default fills with empty map)
1178    /// - Version 5 → Version 6 (language_policy_enabled field added —
1179    ///   defaults to false via `#[serde(default = "default_false")]`)
1180    /// - Version 8 → Version 9 (model_roles field added — no value
1181    ///   migration, `#[serde(default)]` fills with an empty map)
1182    fn migrate(settings: Settings) -> Result<Settings> {
1183        let mut settings = settings;
1184
1185        match settings.version {
1186            SETTINGS_VERSION => {
1187                // Already current — nothing to do.
1188            }
1189            0 => {
1190                // Version 0 = pre-versioning config.
1191                // Add any defaults that were introduced in version 1.
1192                if settings.tool_timeout_seconds == 0 {
1193                    settings.tool_timeout_seconds = default_tool_timeout();
1194                }
1195                settings.version = SETTINGS_VERSION;
1196
1197                tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1198            }
1199            1 | 2 => {
1200                // Version 1/2 → 6: dynamic_models field added + model/provider split.
1201                // The v3 → v4 default_model → last_used_model split doesn't apply
1202                // here (no default_model in v1/v2). `#[serde(default)]` fills
1203                // output_languages (empty) and language_policy_enabled (false).
1204                settings.version = SETTINGS_VERSION;
1205                tracing::info!(
1206                    "Migrated settings from version {} to {} (dynamic_models + output_languages + language_policy_enabled defaults applied)",
1207                    settings.version,
1208                    SETTINGS_VERSION
1209                );
1210            }
1211            3 => {
1212                // Version 3 → 4 step happens inline: migrate default_model → last_used_model.
1213                if let Some(model) = settings.default_model.take() {
1214                    if let Some((provider, model_name)) = model.split_once('/') {
1215                        settings.last_used_provider = Some(provider.to_string());
1216                        settings.last_used_model = Some(model_name.to_string());
1217                    } else {
1218                        settings.last_used_model = Some(model);
1219                    }
1220                }
1221                // Then collapse to v6: output_languages + language_policy_enabled default.
1222                settings.version = SETTINGS_VERSION;
1223                tracing::info!(
1224                    "Migrated settings from version 3 to {} (default_model → last_used_model; output_languages + language_policy_enabled defaults)",
1225                    SETTINGS_VERSION
1226                );
1227            }
1228            4 => {
1229                // Version 4 → 5 (output_languages field added) collapses to v6.
1230                // No value migration needed — `#[serde(default)]` fills the
1231                // missing fields with empty map + language_policy_enabled = false.
1232                settings.version = SETTINGS_VERSION;
1233                tracing::info!(
1234                    "Migrated settings from version 4 to {} (added output_languages + language_policy_enabled, both defaulted to off)",
1235                    SETTINGS_VERSION
1236                );
1237            }
1238            5 => {
1239                // Version 5 → 6: language_policy_enabled field added.
1240                // `#[serde(default = "default_false")]` fills with false (opt-in).
1241                // Existing v5 users with output_languages configured will see
1242                // their channel mappings preserved but disabled until they
1243                // toggle the master switch ON in /settings.
1244                settings.version = SETTINGS_VERSION;
1245                tracing::info!(
1246                    "Migrated settings from version 5 to {} (added language_policy_enabled, defaulting to OFF — toggle ON in /settings to activate existing channels)",
1247                    SETTINGS_VERSION
1248                );
1249            }
1250            6 => {
1251                // Version 6 → 7: edit_format field added.
1252                // `#[serde(default)]` fills with EditFormat::StrReplace (default).
1253                settings.version = SETTINGS_VERSION;
1254                tracing::info!(
1255                    "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1256                    SETTINGS_VERSION
1257                );
1258            }
1259            7 => {
1260                // Version 7 → 8: glyph_set field added.
1261                // `#[serde(default)]` fills with GlyphSet::Unicode (default).
1262                settings.version = SETTINGS_VERSION;
1263                tracing::info!(
1264                    "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1265                    SETTINGS_VERSION
1266                );
1267            }
1268            8 => {
1269                // Version 8 → 9: model_roles field added (ported from omp).
1270                // No value migration — `#[serde(default)]` fills an empty map.
1271                settings.version = SETTINGS_VERSION;
1272                tracing::info!(
1273                    "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1274                    SETTINGS_VERSION
1275                );
1276            }
1277            v if v > SETTINGS_VERSION => {
1278                // Future version — we don't know how to downgrade.
1279                anyhow::bail!(
1280                    "Settings version {} is newer than supported version {}. \
1281                     Please update oxi.",
1282                    v,
1283                    SETTINGS_VERSION
1284                );
1285            }
1286            v => {
1287                // Unknown old version — best-effort migration.
1288                tracing::warn!(
1289                    "Unknown settings version {}, attempting migration to {}",
1290                    v,
1291                    SETTINGS_VERSION
1292                );
1293                settings.version = SETTINGS_VERSION;
1294            }
1295        }
1296
1297        Ok(settings)
1298    }
1299}
1300
1301// ── Settings format detection ──────────────────────────────────────
1302
1303/// Supported settings file formats.
1304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1305pub enum SettingsFormat {
1306    /// JSON format.
1307    #[default]
1308    Json,
1309    /// TOML format.
1310    Toml,
1311}
1312
1313impl SettingsFormat {
1314    /// Get the file extension for this format.
1315    pub fn extension(&self) -> &'static str {
1316        match self {
1317            SettingsFormat::Json => "json",
1318            SettingsFormat::Toml => "toml",
1319        }
1320    }
1321}
1322
1323// ── JSON/TOML conversion helpers ────────────────────────────────────
1324
1325/// Convert a TOML Value to a serde_json::Value.
1326fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1327    match toml {
1328        toml::Value::String(s) => serde_json::Value::String(s),
1329        toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1330        toml::Value::Float(f) => serde_json::Number::from_f64(f)
1331            .map(serde_json::Value::Number)
1332            .unwrap_or(serde_json::Value::Null),
1333        toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1334        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1335        toml::Value::Array(arr) => {
1336            serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1337        }
1338        toml::Value::Table(table) => {
1339            let obj = table
1340                .into_iter()
1341                .map(|(k, v)| (k, toml_value_to_json(v)))
1342                .collect();
1343            serde_json::Value::Object(obj)
1344        }
1345    }
1346}
1347
1348/// Deep merge two JSON values. The second value overrides the first.
1349fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1350    match (base, override_) {
1351        // If either is not an object, the override wins
1352        (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1353            let mut result = base_map;
1354            for (key, override_value) in override_map {
1355                let base_value = result.remove(&key);
1356                let merged = match base_value {
1357                    Some(base_v) => merge_json_values(base_v, override_value),
1358                    None => override_value,
1359                };
1360                result.insert(key, merged);
1361            }
1362            serde_json::Value::Object(result)
1363        }
1364        // Override wins for non-objects
1365        (_, override_) => override_,
1366    }
1367}
1368
1369/// Parse a thinking level from a string.
1370pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1371    match s.to_lowercase().as_str() {
1372        "off" | "none" => Some(ThinkingLevel::Off),
1373        "minimal" => Some(ThinkingLevel::Minimal),
1374        "low" => Some(ThinkingLevel::Low),
1375        "medium" | "standard" => Some(ThinkingLevel::Medium),
1376        "high" | "thorough" => Some(ThinkingLevel::High),
1377        "xhigh" => Some(ThinkingLevel::XHigh),
1378        _ => None,
1379    }
1380}
1381
1382/// Parse a boolean-like string (`"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"`).
1383#[allow(dead_code)]
1384fn parse_boolish(s: &str) -> Result<bool> {
1385    match s.to_lowercase().as_str() {
1386        "true" | "1" | "yes" | "on" => Ok(true),
1387        "false" | "0" | "no" | "off" => Ok(false),
1388        _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1389    }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394    use super::*;
1395    use std::io::Write as IoWrite;
1396    use std::sync::Mutex;
1397
1398    /// Global lock to serialize all tests that manipulate process-wide env vars.
1399    #[allow(dead_code)] // held implicitly via guard pattern; not all tests acquire it
1400    static ENV_LOCK: Mutex<()> = Mutex::new(());
1401
1402    /// RAII guard that removes listed env vars on creation and restores them on drop.
1403    /// This prevents parallel test races where one test sets an env var that leaks into another.
1404    struct EnvGuard {
1405        saved: Vec<(String, Option<String>)>,
1406    }
1407
1408    impl EnvGuard {
1409        fn new(vars: &[&str]) -> Self {
1410            let saved = vars
1411                .iter()
1412                .map(|&name| {
1413                    let old = env::var(name).ok();
1414                    // SAFETY: test-only; the ENV_LOCK mutex serializes access.
1415                    unsafe { env::remove_var(name) };
1416                    (name.to_string(), old)
1417                })
1418                .collect();
1419            Self { saved }
1420        }
1421    }
1422
1423    impl Drop for EnvGuard {
1424        fn drop(&mut self) {
1425            for (name, old) in self.saved.drain(..) {
1426                match old {
1427                    // SAFETY: test-only; the ENV_LOCK mutex serializes access.
1428                    Some(val) => unsafe { env::set_var(&name, val) },
1429                    None => unsafe { env::remove_var(&name) },
1430                }
1431            }
1432        }
1433    }
1434
1435    // ── Struct tests ─────────────────────────────────────────────────
1436
1437    #[test]
1438    fn test_default_settings() {
1439        let settings = Settings::default();
1440        assert_eq!(settings.version, SETTINGS_VERSION);
1441        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1442        assert_eq!(settings.theme, "default");
1443        assert!(settings.last_used_model.is_none());
1444        assert!(settings.last_used_provider.is_none());
1445        assert!(settings.extensions_enabled);
1446        assert!(settings.auto_compaction);
1447        assert_eq!(settings.tool_timeout_seconds, 120);
1448        assert!(settings.stream_responses);
1449    }
1450
1451    #[test]
1452    fn test_merge_cli() {
1453        let mut settings = Settings::default();
1454        settings.last_used_model = Some("gpt-4o".to_string());
1455
1456        settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1457        assert_eq!(settings.last_used_model, Some("claude".to_string()));
1458
1459        settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1460        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1461
1462        // Test routing flags
1463        settings.merge_cli(
1464            None,
1465            None,
1466            Some(true),
1467            Some(false),
1468            Some(vec!["openai/gpt-4o".to_string()]),
1469            Some(false),
1470        );
1471        assert!(settings.enable_routing);
1472        assert!(!settings.prefer_cost_efficient);
1473        assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1474        assert!(!settings.disable_fallback);
1475
1476        // Test disable_fallback sets enable_fallback to false
1477        let mut settings2 = Settings::default();
1478        settings2.merge_cli(None, None, None, None, None, Some(true));
1479        assert!(settings2.disable_fallback);
1480        assert!(!settings2.enable_fallback);
1481    }
1482
1483    // ── Layered loading ──────────────────────────────────────────────
1484
1485    #[test]
1486    fn test_layer_file_overrides() {
1487        let base = Settings::default();
1488
1489        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1490        let toml_content = r#"
1491last_used_model = "openai/gpt-4o"
1492theme = "dracula"
1493"#;
1494        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1495
1496        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1497        assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1498        assert_eq!(merged.theme, "dracula");
1499        // Unchanged fields retain defaults
1500        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1501        assert!(merged.extensions_enabled);
1502    }
1503
1504    #[test]
1505    fn test_layer_file_preserves_unset() {
1506        let mut base = Settings::default();
1507        base.last_used_provider = Some("deepseek".to_string());
1508
1509        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1510        // Only override theme — provider should remain
1511        let toml_content = "theme = \"monokai\"\n";
1512        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1513
1514        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1515        assert_eq!(merged.theme, "monokai");
1516        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1517    }
1518
1519    #[test]
1520    fn test_load_from_dir_with_project_config() {
1521        let _guard = EnvGuard::new(&[
1522            "OXI_MODEL",
1523            "OXI_PROVIDER",
1524            "OXI_THEME",
1525            "OXI_TOOL_TIMEOUT",
1526            "OXI_TEMPERATURE",
1527            "OXI_MAX_TOKENS",
1528            "OXI_SESSION_DIR",
1529            "OXI_STREAM",
1530            "OXI_EXTENSIONS_ENABLED",
1531        ]);
1532        let tmp = tempfile::tempdir().unwrap();
1533        let oxi_dir = tmp.path().join(".oxi");
1534        fs::create_dir_all(&oxi_dir).unwrap();
1535        let settings_path = oxi_dir.join("settings.toml");
1536        // Write v3 format: default_model contains "provider/model"
1537        fs::write(
1538            &settings_path,
1539            "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1540        )
1541        .unwrap();
1542
1543        let settings = Settings::load_from(tmp.path()).unwrap();
1544        // Migration moves default_model → last_used_model
1545        assert_eq!(
1546            settings.last_used_model,
1547            Some("gemini-2.0-flash".to_string())
1548        );
1549        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1550    }
1551
1552    #[test]
1553    fn test_load_from_dir_no_config() {
1554        // Clean env vars that load_from() reads via apply_env()
1555        let _guard = EnvGuard::new(&[
1556            "OXI_MODEL",
1557            "OXI_PROVIDER",
1558            "OXI_THEME",
1559            "OXI_TOOL_TIMEOUT",
1560            "OXI_TEMPERATURE",
1561            "OXI_MAX_TOKENS",
1562            "OXI_SESSION_DIR",
1563            "OXI_STREAM",
1564            "OXI_EXTENSIONS_ENABLED",
1565        ]);
1566        let tmp = tempfile::tempdir().unwrap();
1567        // Pass a nonexistent global path so the real `~/.oxi/settings.*`
1568        // never leaks into the test. (`Settings::load_from` reads the
1569        // real global config when present, which is what made this test
1570        // fail when the user's global set `thinking_level = "high"`.)
1571        let global = tmp.path().join("nonexistent-settings.json");
1572        let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1573        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1574    }
1575    #[test]
1576    fn test_from_env() {
1577        // NOTE: Environment variable overrides are disabled.
1578        // from_env() returns defaults only.
1579        let _guard = EnvGuard::new(&[
1580            // no env vars to clear
1581            "OXI_MODEL",
1582            "OXI_THEME",
1583            "OXI_TOOL_TIMEOUT",
1584            "OXI_PROVIDER",
1585            "OXI_DEFAULT_MODEL",
1586        ]);
1587
1588        let settings = Settings::from_env();
1589        // All fields should be at defaults since env overrides are disabled
1590        assert_eq!(settings.last_used_model, None);
1591        assert_eq!(settings.theme, "default");
1592        assert_eq!(settings.tool_timeout_seconds, 120);
1593    }
1594
1595    #[test]
1596    fn test_apply_env_boolish() {
1597        // NOTE: Environment variable overrides are disabled.
1598        // apply_env() is a no-op.
1599        let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1600        unsafe { env::set_var("OXI_STREAM", "false") };
1601        unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1602
1603        let mut settings = Settings::default();
1604        settings.apply_env();
1605        // Since env overrides are disabled, values stay at defaults
1606        assert!(settings.stream_responses); // default is true
1607        assert!(settings.extensions_enabled); // default is true
1608    }
1609
1610    #[test]
1611    fn test_apply_env_temperature() {
1612        // NOTE: Environment variable overrides are disabled.
1613        let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1614        unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1615
1616        let mut settings = Settings::default();
1617        settings.apply_env();
1618        // Since env overrides are disabled, temperature stays at None
1619        assert_eq!(settings.default_temperature, None);
1620    }
1621
1622    #[test]
1623    fn test_env_does_not_override_when_unset() {
1624        let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1625        let settings = Settings::from_env();
1626        assert!(settings.last_used_model.is_none());
1627        assert!(settings.last_used_provider.is_none());
1628    }
1629
1630    #[test]
1631    fn test_parse_thinking_level() {
1632        assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1633        assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1634        assert_eq!(
1635            parse_thinking_level("MINIMAL"),
1636            Some(ThinkingLevel::Minimal)
1637        );
1638        assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1639        assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1640        assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1641        assert_eq!(
1642            parse_thinking_level("Standard"),
1643            Some(ThinkingLevel::Medium)
1644        );
1645        assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1646        assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1647        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1648        assert_eq!(parse_thinking_level("invalid"), None);
1649    }
1650
1651    #[test]
1652    fn test_parse_boolish() {
1653        assert!(parse_boolish("true").unwrap());
1654        assert!(parse_boolish("1").unwrap());
1655        assert!(parse_boolish("yes").unwrap());
1656        assert!(parse_boolish("ON").unwrap());
1657        assert!(!parse_boolish("false").unwrap());
1658        assert!(!parse_boolish("0").unwrap());
1659        assert!(!parse_boolish("no").unwrap());
1660        assert!(!parse_boolish("OFF").unwrap());
1661        assert!(parse_boolish("maybe").is_err());
1662    }
1663
1664    // ── Effective accessors ──────────────────────────────────────────
1665
1666    #[test]
1667    fn test_effective_model_returns_last_used() {
1668        let mut settings = Settings::default();
1669        settings.last_used_model = Some("openai/gpt-4o".to_string());
1670        assert_eq!(
1671            settings.effective_model(None),
1672            Some("openai/gpt-4o".to_string())
1673        );
1674    }
1675
1676    #[test]
1677    fn test_effective_model_cli_overrides() {
1678        let mut settings = Settings::default();
1679        settings.last_used_model = Some("openai/gpt-4o".to_string());
1680        assert_eq!(
1681            settings.effective_model(Some("anthropic/claude-3")),
1682            Some("anthropic/claude-3".to_string())
1683        );
1684    }
1685
1686    #[test]
1687    fn test_effective_model_none_when_unset() {
1688        let settings = Settings::default();
1689        assert_eq!(settings.effective_model(None), None);
1690    }
1691
1692    #[test]
1693    fn test_effective_model_falls_back_to_last_used() {
1694        let mut settings = Settings::default();
1695        settings.last_used_model = Some("anthropic/claude-3".to_string());
1696        assert_eq!(
1697            settings.effective_model(None),
1698            Some("anthropic/claude-3".to_string())
1699        );
1700    }
1701
1702    #[test]
1703    fn test_effective_model_returns_none_when_nothing_set() {
1704        let settings = Settings::default();
1705        assert_eq!(settings.effective_model(None), None);
1706    }
1707
1708    #[test]
1709    fn test_effective_temperature_prefers_f64() {
1710        let mut settings = Settings::default();
1711        settings.temperature = Some(0.5);
1712        settings.default_temperature = Some(0.7);
1713        assert_eq!(settings.effective_temperature(), Some(0.7));
1714    }
1715
1716    #[test]
1717    fn test_effective_temperature_falls_back_to_f32() {
1718        let mut settings = Settings::default();
1719        settings.temperature = Some(0.5);
1720        assert_eq!(settings.effective_temperature(), Some(0.5));
1721    }
1722
1723    #[test]
1724    fn test_effective_max_tokens_prefers_usize() {
1725        let mut settings = Settings::default();
1726        settings.max_tokens = Some(1024);
1727        settings.max_response_tokens = Some(4096);
1728        assert_eq!(settings.effective_max_tokens(), Some(4096));
1729    }
1730
1731    #[test]
1732    fn test_effective_max_tokens_falls_back_to_u32() {
1733        let mut settings = Settings::default();
1734        settings.max_tokens = Some(1024);
1735        assert_eq!(settings.effective_max_tokens(), Some(1024));
1736    }
1737
1738    // ── Session dir ──────────────────────────────────────────────────
1739
1740    #[test]
1741    fn test_effective_session_dir_default() {
1742        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1743        let settings = Settings::default();
1744        let dir = settings.effective_session_dir().unwrap();
1745        assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1746    }
1747
1748    #[test]
1749    fn test_effective_session_dir_from_field() {
1750        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1751        let mut settings = Settings::default();
1752        settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1753        assert_eq!(
1754            settings.effective_session_dir().unwrap(),
1755            PathBuf::from("/tmp/oxi-sessions")
1756        );
1757    }
1758
1759    #[test]
1760    fn test_effective_session_dir_env_disabled() {
1761        // NOTE: Environment variable overrides are disabled.
1762        // OXI_SESSION_DIR is ignored; effective_session_dir() returns the field value (or default).
1763        let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1764        unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1765        let settings = Settings::default();
1766        // Env is ignored, so it should use the default path, not /tmp/env-sessions
1767        let dir = settings.effective_session_dir().unwrap();
1768        assert!(
1769            dir.ends_with("sessions"),
1770            "expected default sessions dir, got: {:?}",
1771            dir
1772        );
1773    }
1774
1775    // ── Migration ────────────────────────────────────────────────────
1776
1777    #[test]
1778    fn test_migration_v0_to_v1() {
1779        let mut settings = Settings::default();
1780        settings.version = 0;
1781        settings.tool_timeout_seconds = 0; // v0 might not have this field
1782
1783        let migrated = Settings::migrate(settings).unwrap();
1784        assert_eq!(migrated.version, SETTINGS_VERSION);
1785        assert_eq!(migrated.tool_timeout_seconds, 120);
1786    }
1787
1788    #[test]
1789    fn test_migration_already_current() {
1790        let settings = Settings::default();
1791        let migrated = Settings::migrate(settings).unwrap();
1792        assert_eq!(migrated.version, SETTINGS_VERSION);
1793    }
1794
1795    #[test]
1796    fn test_migration_v3_to_v4_splits_model() {
1797        let mut settings = Settings::default();
1798        settings.version = 3;
1799        settings.default_model = Some("openai/gpt-4o".to_string());
1800        settings.default_provider = None;
1801
1802        let migrated = Settings::migrate(settings).unwrap();
1803        assert_eq!(migrated.version, SETTINGS_VERSION);
1804        assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1805        assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1806    }
1807
1808    #[test]
1809    fn test_migration_v3_no_slash_keeps_model() {
1810        let mut settings = Settings::default();
1811        settings.version = 3;
1812        settings.default_model = Some("bare-model-name".to_string());
1813
1814        let migrated = Settings::migrate(settings).unwrap();
1815        assert_eq!(migrated.version, SETTINGS_VERSION);
1816        assert_eq!(
1817            migrated.last_used_model,
1818            Some("bare-model-name".to_string())
1819        );
1820    }
1821
1822    #[test]
1823    fn test_migration_future_version_fails() {
1824        let mut settings = Settings::default();
1825        settings.version = 9999;
1826        assert!(Settings::migrate(settings).is_err());
1827    }
1828
1829    // ── output_languages tests (TUI language policy, v5) ────────────
1830
1831    #[test]
1832    fn test_default_output_languages_is_empty() {
1833        let settings = Settings::default();
1834        assert!(
1835            settings.output_languages.is_empty(),
1836            "all channels should default to auto (empty map)"
1837        );
1838    }
1839
1840    #[test]
1841    fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1842        let mut settings = Settings::default();
1843        settings.version = 4;
1844        settings
1845            .output_languages
1846            .insert("response".to_string(), "ko".to_string());
1847        settings
1848            .output_languages
1849            .insert("commit_message".to_string(), "en".to_string());
1850
1851        let migrated = Settings::migrate(settings).unwrap();
1852        assert_eq!(migrated.version, SETTINGS_VERSION);
1853        assert_eq!(
1854            migrated.output_languages.get("response"),
1855            Some(&"ko".to_string())
1856        );
1857        assert_eq!(
1858            migrated.output_languages.get("commit_message"),
1859            Some(&"en".to_string())
1860        );
1861    }
1862
1863    #[test]
1864    fn test_migration_v4_to_v5_creates_empty_if_missing() {
1865        // A v4 file loaded fresh will not have `output_languages` at all —
1866        // serde fills it with an empty HashMap via `#[serde(default)]`.
1867        // After migration, version is bumped to 5 with the empty map intact.
1868        let mut settings = Settings::default();
1869        settings.version = 4;
1870        assert!(settings.output_languages.is_empty());
1871
1872        let migrated = Settings::migrate(settings).unwrap();
1873        assert_eq!(migrated.version, SETTINGS_VERSION);
1874        assert!(migrated.output_languages.is_empty());
1875    }
1876
1877    #[test]
1878    fn test_validate_keeps_user_defined_channel() {
1879        // Per the extension-map contract, ANY channel key must be
1880        // accepted (known or user-defined). The validator must NOT
1881        // drop unknown channels — `language_directive` will use the
1882        // raw key as a label fallback.
1883        let mut settings = Settings::default();
1884        settings
1885            .output_languages
1886            .insert("pr_description".to_string(), "en".to_string()); // user-defined
1887        settings
1888            .output_languages
1889            .insert("response".to_string(), "ko".to_string()); // known
1890
1891        settings.validate_output_languages();
1892
1893        assert!(settings.output_languages.contains_key("pr_description"));
1894        assert!(settings.output_languages.contains_key("response"));
1895        assert_eq!(
1896            settings.output_languages.get("pr_description"),
1897            Some(&"en".to_string())
1898        );
1899        assert_eq!(
1900            settings.output_languages.get("response"),
1901            Some(&"ko".to_string())
1902        );
1903    }
1904
1905    #[test]
1906    fn test_validate_keeps_unknown_lang_with_warning() {
1907        let mut settings = Settings::default();
1908        settings
1909            .output_languages
1910            .insert("response".to_string(), "klingon".to_string()); // unknown code
1911        settings
1912            .output_languages
1913            .insert("commit_message".to_string(), "en".to_string()); // known
1914
1915        settings.validate_output_languages();
1916
1917        // Unknown code is KEPT (with a warn log) so users can add languages
1918        // without code changes.
1919        assert_eq!(
1920            settings.output_languages.get("response"),
1921            Some(&"klingon".to_string())
1922        );
1923        assert_eq!(
1924            settings.output_languages.get("commit_message"),
1925            Some(&"en".to_string())
1926        );
1927    }
1928
1929    #[test]
1930    fn test_known_channels_table_includes_core_four() {
1931        let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1932        assert!(keys.contains(&"response"));
1933        assert!(keys.contains(&"code_comment"));
1934        assert!(keys.contains(&"documentation"));
1935        assert!(keys.contains(&"commit_message"));
1936    }
1937
1938    #[test]
1939    fn test_known_langs_table_includes_auto_and_english() {
1940        let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1941        assert!(codes.contains(&"auto"));
1942        assert!(codes.contains(&"en"));
1943    }
1944
1945    #[test]
1946    fn test_default_language_policy_enabled_is_false() {
1947        // v6: master toggle defaults to OFF (opt-in).
1948        let settings = Settings::default();
1949        assert!(
1950            !settings.language_policy_enabled,
1951            "language_policy_enabled must default to false (opt-in)"
1952        );
1953    }
1954
1955    #[test]
1956    fn test_migration_v5_to_v6_defaults_master_toggle_to_off() {
1957        // v5 settings (with output_languages configured) should migrate to v6
1958        // with language_policy_enabled = false. Channel mappings are preserved
1959        // but disabled until the user flips the master switch.
1960        let mut settings = Settings::default();
1961        settings.version = 5;
1962        settings
1963            .output_languages
1964            .insert("response".to_string(), "ko".to_string());
1965        settings
1966            .output_languages
1967            .insert("commit_message".to_string(), "en".to_string());
1968
1969        let migrated = Settings::migrate(settings).unwrap();
1970        assert_eq!(migrated.version, SETTINGS_VERSION);
1971        assert!(
1972            !migrated.language_policy_enabled,
1973            "v5 → v6 migration must default language_policy_enabled to false"
1974        );
1975        // Channel mappings are preserved verbatim.
1976        assert_eq!(
1977            migrated.output_languages.get("response"),
1978            Some(&"ko".to_string())
1979        );
1980        assert_eq!(
1981            migrated.output_languages.get("commit_message"),
1982            Some(&"en".to_string())
1983        );
1984    }
1985
1986    #[test]
1987    fn test_default_glyph_set_is_unicode() {
1988        let settings = Settings::default();
1989        assert_eq!(
1990            settings.glyph_set,
1991            GlyphSet::Unicode,
1992            "glyph_set must default to Unicode"
1993        );
1994    }
1995
1996    #[test]
1997    fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1998        // v7 settings (no glyph_set field on disk) deserialize with the serde
1999        // default (Unicode) and migrate to v8.
2000        let mut settings = Settings::default();
2001        settings.version = 7;
2002        // Simulate a freshly-loaded v7 file: glyph_set unset → default.
2003        settings.glyph_set = GlyphSet::default();
2004
2005        let migrated = Settings::migrate(settings).unwrap();
2006        assert_eq!(migrated.version, SETTINGS_VERSION);
2007        assert_eq!(
2008            migrated.glyph_set,
2009            GlyphSet::Unicode,
2010            "v7 → v8 migration must default glyph_set to unicode"
2011        );
2012    }
2013
2014    #[test]
2015    fn test_glyph_set_persists_through_roundtrip() {
2016        // Direct TOML serialize → deserialize exercises the on-disk
2017        // snake_case form (`glyph_set = "nerd"`) without depending on
2018        // the layered `load_from` directory walk.
2019        let mut original = Settings::default();
2020        original.glyph_set = GlyphSet::Nerd;
2021        let content = toml::to_string_pretty(&original).unwrap();
2022        assert!(
2023            content.contains("glyph_set = \"nerd\""),
2024            "nerd preset must serialize to snake_case; got:\n{content}"
2025        );
2026        let loaded: Settings = toml::from_str(&content).unwrap();
2027        assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
2028        // Unicode round-trips too.
2029        original.glyph_set = GlyphSet::Unicode;
2030        let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
2031        assert_eq!(uni.glyph_set, GlyphSet::Unicode);
2032    }
2033
2034    #[test]
2035    fn test_save_and_load_roundtrip_preserves_language_policy_enabled() {
2036        let tmp = tempfile::tempdir().unwrap();
2037        let settings_path = tmp.path().join("settings.toml");
2038
2039        let mut original = Settings::default();
2040        original.language_policy_enabled = true;
2041        original
2042            .output_languages
2043            .insert("response".to_string(), "ko".to_string());
2044
2045        let content = toml::to_string_pretty(&original).unwrap();
2046        fs::write(&settings_path, &content).unwrap();
2047
2048        let loaded_content = fs::read_to_string(&settings_path).unwrap();
2049        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2050
2051        assert!(loaded.language_policy_enabled);
2052        assert_eq!(
2053            loaded.output_languages.get("response"),
2054            Some(&"ko".to_string())
2055        );
2056    }
2057
2058    #[test]
2059    fn test_save_and_load_roundtrip_preserves_output_languages() {
2060        let tmp = tempfile::tempdir().unwrap();
2061        let settings_path = tmp.path().join("settings.toml");
2062
2063        let mut original = Settings::default();
2064        original
2065            .output_languages
2066            .insert("response".to_string(), "ko".to_string());
2067        original
2068            .output_languages
2069            .insert("commit_message".to_string(), "en".to_string());
2070
2071        let content = toml::to_string_pretty(&original).unwrap();
2072        fs::write(&settings_path, &content).unwrap();
2073
2074        let loaded_content = fs::read_to_string(&settings_path).unwrap();
2075        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2076
2077        assert_eq!(
2078            loaded.output_languages.get("response"),
2079            Some(&"ko".to_string())
2080        );
2081        assert_eq!(
2082            loaded.output_languages.get("commit_message"),
2083            Some(&"en".to_string())
2084        );
2085    }
2086
2087    // ── Persistence ──────────────────────────────────────────────────
2088
2089    #[test]
2090    fn test_save_and_load_roundtrip() {
2091        let tmp = tempfile::tempdir().unwrap();
2092        let settings_path = tmp.path().join("settings.toml");
2093
2094        let mut original = Settings::default();
2095        original.last_used_model = Some("gpt-4o".to_string());
2096        original.last_used_provider = Some("openai".to_string());
2097        original.theme = "dracula".to_string();
2098        original.tool_timeout_seconds = 60;
2099
2100        // Serialize
2101        let content = toml::to_string_pretty(&original).unwrap();
2102        fs::write(&settings_path, &content).unwrap();
2103
2104        // Deserialize
2105        let loaded_content = fs::read_to_string(&settings_path).unwrap();
2106        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2107
2108        assert_eq!(loaded.last_used_model, original.last_used_model);
2109        assert_eq!(loaded.theme, original.theme);
2110        assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
2111    }
2112
2113    #[test]
2114    fn test_toml_roundtrip_preserves_new_fields() {
2115        let mut settings = Settings::default();
2116        settings.default_temperature = Some(0.8);
2117        settings.max_response_tokens = Some(8192);
2118        settings.auto_compaction = false;
2119        settings.extensions_enabled = false;
2120        settings.session_dir = Some(PathBuf::from("/custom/sessions"));
2121
2122        let toml_str = toml::to_string_pretty(&settings).unwrap();
2123        let parsed: Settings = toml::from_str(&toml_str).unwrap();
2124
2125        assert_eq!(parsed.default_temperature, Some(0.8));
2126        assert_eq!(parsed.max_response_tokens, Some(8192));
2127        assert!(!parsed.auto_compaction);
2128        assert!(!parsed.extensions_enabled);
2129        assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
2130    }
2131
2132    // ── JSON format tests ──────────────────────────────────────────────
2133
2134    #[test]
2135    fn test_json_roundtrip() {
2136        let mut settings = Settings::default();
2137        settings.last_used_model = Some("gpt-4o".to_string());
2138        settings.last_used_provider = Some("openai".to_string());
2139        settings.theme = "dracula".to_string();
2140        settings.tool_timeout_seconds = 60;
2141        settings.default_temperature = Some(0.8);
2142        settings.max_response_tokens = Some(8192);
2143
2144        let json_str = serde_json::to_string_pretty(&settings).unwrap();
2145        let parsed: Settings = serde_json::from_str(&json_str).unwrap();
2146
2147        assert_eq!(parsed.last_used_model, settings.last_used_model);
2148        assert_eq!(parsed.theme, settings.theme);
2149        assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
2150        assert_eq!(parsed.default_temperature, settings.default_temperature);
2151        assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
2152    }
2153
2154    #[test]
2155    fn test_json_serialize_for_format() {
2156        let mut settings = Settings::default();
2157        settings.last_used_model = Some("claude-3".to_string());
2158        settings.last_used_provider = Some("anthropic".to_string());
2159        settings.thinking_level = ThinkingLevel::Minimal;
2160
2161        let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
2162        let parsed: Settings = serde_json::from_str(&json_content).unwrap();
2163
2164        assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
2165        assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
2166    }
2167
2168    #[test]
2169    fn test_toml_serialize_for_format() {
2170        let mut settings = Settings::default();
2171        settings.last_used_model = Some("gemini-pro".to_string());
2172        settings.last_used_provider = Some("google".to_string());
2173        settings.thinking_level = ThinkingLevel::High;
2174
2175        let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
2176        let parsed: Settings = toml::from_str(&toml_content).unwrap();
2177
2178        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2179        assert_eq!(parsed.thinking_level, ThinkingLevel::High);
2180    }
2181
2182    #[test]
2183    fn test_parse_from_str_json() {
2184        let json_content = r#"{
2185            "last_used_model": "gpt-4",
2186            "last_used_provider": "openai",
2187            "theme": "nord",
2188            "tool_timeout_seconds": 90
2189        }"#;
2190
2191        let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
2192        assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
2193        assert_eq!(settings.last_used_provider, Some("openai".to_string()));
2194        assert_eq!(settings.theme, "nord");
2195        assert_eq!(settings.tool_timeout_seconds, 90);
2196        // Unchanged fields retain defaults
2197        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2198        assert!(settings.extensions_enabled);
2199    }
2200
2201    #[test]
2202    fn test_parse_from_str_toml() {
2203        let toml_content = r#"
2204last_used_model = "claude-opus"
2205last_used_provider = "anthropic"
2206theme = "monokai"
2207tool_timeout_seconds = 45
2208"#;
2209
2210        let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
2211        assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
2212        assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
2213        assert_eq!(settings.theme, "monokai");
2214        assert_eq!(settings.tool_timeout_seconds, 45);
2215        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2216    }
2217
2218    #[test]
2219    fn test_layer_file_json() {
2220        let base = Settings::default();
2221
2222        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2223        let json_content = r#"{
2224            "last_used_model": "gpt-4o",
2225            "last_used_provider": "openai",
2226            "theme": "dracula",
2227            "auto_compaction": false
2228        }"#;
2229        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2230
2231        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2232        assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
2233        assert_eq!(merged.last_used_provider, Some("openai".to_string()));
2234        assert_eq!(merged.theme, "dracula");
2235        assert!(!merged.auto_compaction);
2236        // Unchanged fields retain defaults
2237        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
2238        assert!(merged.extensions_enabled);
2239        assert_eq!(merged.tool_timeout_seconds, 120);
2240    }
2241
2242    #[test]
2243    fn test_layer_file_json_preserves_unset() {
2244        let mut base = Settings::default();
2245        base.last_used_provider = Some("deepseek".to_string());
2246
2247        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2248        let json_content = r#"{ "theme": "nord" }"#;
2249        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2250
2251        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2252        assert_eq!(merged.theme, "nord");
2253        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
2254    }
2255
2256    #[test]
2257    fn test_save_to_json() {
2258        let tmp = tempfile::tempdir().unwrap();
2259        let settings_path = tmp.path().join("settings.json");
2260
2261        let mut settings = Settings::default();
2262        settings.last_used_model = Some("gpt-4o".to_string());
2263        settings.last_used_provider = Some("openai".to_string());
2264        settings.theme = "dracula".to_string();
2265        settings.tool_timeout_seconds = 60;
2266
2267        settings.save_to(&settings_path).unwrap();
2268
2269        // Verify it's valid JSON
2270        let content = fs::read_to_string(&settings_path).unwrap();
2271        let parsed: Settings = serde_json::from_str(&content).unwrap();
2272        assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
2273        assert_eq!(parsed.theme, "dracula");
2274        assert_eq!(parsed.tool_timeout_seconds, 60);
2275    }
2276
2277    #[test]
2278    fn test_save_to_toml() {
2279        let tmp = tempfile::tempdir().unwrap();
2280        let settings_path = tmp.path().join("settings.toml");
2281
2282        let mut settings = Settings::default();
2283        settings.last_used_model = Some("gemini-pro".to_string());
2284        settings.last_used_provider = Some("google".to_string());
2285        settings.theme = "monokai".to_string();
2286        settings.tool_timeout_seconds = 90;
2287
2288        settings.save_to(&settings_path).unwrap();
2289
2290        // Verify it's valid TOML
2291        let content = fs::read_to_string(&settings_path).unwrap();
2292        let parsed: Settings = toml::from_str(&content).unwrap();
2293        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2294        assert_eq!(parsed.theme, "monokai");
2295        assert_eq!(parsed.tool_timeout_seconds, 90);
2296    }
2297
2298    #[test]
2299    fn test_load_from_dir_with_json_project_config() {
2300        let _guard = EnvGuard::new(&[
2301            "OXI_MODEL",
2302            "OXI_PROVIDER",
2303            "OXI_THEME",
2304            "OXI_TOOL_TIMEOUT",
2305            "OXI_TEMPERATURE",
2306            "OXI_MAX_TOKENS",
2307            "OXI_SESSION_DIR",
2308            "OXI_STREAM",
2309            "OXI_EXTENSIONS_ENABLED",
2310        ]);
2311        let tmp = tempfile::tempdir().unwrap();
2312        let oxi_dir = tmp.path().join(".oxi");
2313        fs::create_dir_all(&oxi_dir).unwrap();
2314        let settings_path = oxi_dir.join("settings.json");
2315        // v3 format: default_model has provider/model
2316        let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
2317        fs::write(&settings_path, json_content).unwrap();
2318
2319        let settings = Settings::load_from(tmp.path()).unwrap();
2320        // Migration splits provider from model
2321        assert_eq!(
2322            settings.last_used_model,
2323            Some("gemini-2.0-flash".to_string())
2324        );
2325        assert_eq!(settings.last_used_provider, Some("google".to_string()));
2326    }
2327
2328    #[test]
2329    fn test_find_project_settings_json_priority() {
2330        let tmp = tempfile::tempdir().unwrap();
2331        let oxi_dir = tmp.path().join(".oxi");
2332        fs::create_dir_all(&oxi_dir).unwrap();
2333
2334        // Create both files
2335        let json_path = oxi_dir.join("settings.json");
2336        let toml_path = oxi_dir.join("settings.toml");
2337        fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2338        fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2339
2340        // JSON takes priority
2341        let found = Settings::find_project_settings(tmp.path());
2342        assert!(found.is_some());
2343        assert_eq!(
2344            found.unwrap().file_name().unwrap().to_str().unwrap(),
2345            "settings.json"
2346        );
2347    }
2348
2349    #[test]
2350    fn test_find_project_settings_json_only() {
2351        let tmp = tempfile::tempdir().unwrap();
2352        let oxi_dir = tmp.path().join(".oxi");
2353        fs::create_dir_all(&oxi_dir).unwrap();
2354
2355        let json_path = oxi_dir.join("settings.json");
2356        fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2357
2358        let found = Settings::find_project_settings(tmp.path());
2359        assert!(found.is_some());
2360        assert_eq!(
2361            found.unwrap().file_name().unwrap().to_str().unwrap(),
2362            "settings.json"
2363        );
2364    }
2365
2366    #[test]
2367    fn test_find_project_settings_toml_fallback() {
2368        let tmp = tempfile::tempdir().unwrap();
2369        let oxi_dir = tmp.path().join(".oxi");
2370        fs::create_dir_all(&oxi_dir).unwrap();
2371
2372        let toml_path = oxi_dir.join("settings.toml");
2373        fs::write(&toml_path, r#"theme = "test""#).unwrap();
2374
2375        let found = Settings::find_project_settings(tmp.path());
2376        assert!(found.is_some());
2377        assert_eq!(
2378            found.unwrap().file_name().unwrap().to_str().unwrap(),
2379            "settings.toml"
2380        );
2381    }
2382
2383    #[test]
2384    fn test_detect_format() {
2385        let json_path = PathBuf::from("/test/settings.json");
2386        let toml_path = PathBuf::from("/test/settings.toml");
2387        let unknown_path = PathBuf::from("/test/settings");
2388
2389        assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2390        assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2391        assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2392        // Default
2393    }
2394
2395    #[test]
2396    fn test_settings_format_extension() {
2397        assert_eq!(SettingsFormat::Json.extension(), "json");
2398        assert_eq!(SettingsFormat::Toml.extension(), "toml");
2399    }
2400
2401    #[test]
2402    fn test_layer_json_over_toml() {
2403        // Test that when loading, JSON takes priority over TOML
2404        let tmp = tempfile::tempdir().unwrap();
2405        let oxi_dir = tmp.path().join(".oxi");
2406        fs::create_dir_all(&oxi_dir).unwrap();
2407
2408        let json_path = oxi_dir.join("settings.json");
2409        let toml_path = oxi_dir.join("settings.toml");
2410
2411        // JSON has model set to "json-model"
2412        fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2413        // TOML has model set to "toml-model"
2414        fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2415
2416        // JSON takes priority
2417        let settings = Settings::load_from(tmp.path()).unwrap();
2418        assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2419    }
2420
2421    #[test]
2422    fn test_mixed_format_loading() {
2423        // Test loading a TOML file through the generic layer_file
2424        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2425        let toml_content = r#"
2426last_used_model = "loaded-via-toml"
2427theme = "loaded-theme"
2428stream_responses = false
2429"#;
2430        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2431
2432        let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2433        assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2434        assert_eq!(merged.theme, "loaded-theme");
2435        assert!(!merged.stream_responses);
2436    }
2437
2438    #[test]
2439    fn test_merge_json_values() {
2440        let base = serde_json::json!({
2441            "version": 1,
2442            "theme": "default",
2443            "extensions": ["ext1"],
2444            "nested": {
2445                "a": 1,
2446                "b": 2
2447            }
2448        });
2449
2450        let override_ = serde_json::json!({
2451            "version": 2,
2452            "theme": "dark",
2453            "extensions": ["ext2"],
2454            "nested": {
2455                "b": 20,
2456                "c": 30
2457            }
2458        });
2459
2460        let merged = merge_json_values(base, override_);
2461
2462        assert_eq!(merged["version"], 2);
2463        assert_eq!(merged["theme"], "dark");
2464        // Arrays are replaced, not merged
2465        assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2466        // Nested objects are deeply merged
2467        assert_eq!(merged["nested"]["a"], 1);
2468        assert_eq!(merged["nested"]["b"], 20);
2469        assert_eq!(merged["nested"]["c"], 30);
2470    }
2471
2472    #[test]
2473    fn test_save_project_preserves_existing_format() {
2474        let tmp = tempfile::tempdir().unwrap();
2475        let oxi_dir = tmp.path().join(".oxi");
2476        fs::create_dir_all(&oxi_dir).unwrap();
2477
2478        // Create existing TOML file
2479        let toml_path = oxi_dir.join("settings.toml");
2480        fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2481
2482        let mut settings = Settings::default();
2483        settings.theme = "new-theme".to_string();
2484        settings.save_project(tmp.path()).unwrap();
2485
2486        // Should still be TOML
2487        let content = fs::read_to_string(&toml_path).unwrap();
2488        assert!(content.contains("new-theme"));
2489        assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2490    }
2491
2492    #[test]
2493    fn test_save_project_creates_json_by_default() {
2494        let tmp = tempfile::tempdir().unwrap();
2495        let oxi_dir = tmp.path().join(".oxi");
2496        fs::create_dir_all(&oxi_dir).unwrap();
2497        // Don't create any settings file
2498
2499        let mut settings = Settings::default();
2500        settings.theme = "json-theme".to_string();
2501        settings.save_project(tmp.path()).unwrap();
2502
2503        // Should create JSON file
2504        let json_path = oxi_dir.join("settings.json");
2505        assert!(json_path.exists());
2506        let content = fs::read_to_string(&json_path).unwrap();
2507        assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2508        assert!(content.contains("json-theme"));
2509    }
2510
2511    // ── Custom provider tests ───────────────────────────────────────
2512
2513    #[test]
2514    fn test_custom_provider_default_api() {
2515        use super::CustomProvider;
2516        let cp = CustomProvider {
2517            name: "test".to_string(),
2518            base_url: "https://api.test.com/v1".to_string(),
2519            api_key_env: "TEST_API_KEY".to_string(),
2520            api: super::default_custom_provider_api(),
2521        };
2522        assert_eq!(cp.api, "openai-completions");
2523    }
2524
2525    #[test]
2526    fn test_custom_provider_toml_deserialize() {
2527        let toml_content = r#"
2528[[custom_providers]]
2529name = "minimax"
2530base_url = "https://api.minimax.chat/v1"
2531api_key_env = "MINIMAX_API_KEY"
2532api = "openai-completions"
2533
2534[[custom_providers]]
2535name = "zai"
2536base_url = "https://api.z.ai/v1"
2537api_key_env = "ZAI_API_KEY"
2538api = "openai-responses"
2539"#;
2540        let settings: Settings = toml::from_str(toml_content).unwrap();
2541        assert_eq!(settings.custom_providers.len(), 2);
2542        assert_eq!(settings.custom_providers[0].name, "minimax");
2543        assert_eq!(
2544            settings.custom_providers[0].base_url,
2545            "https://api.minimax.chat/v1"
2546        );
2547        assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2548        assert_eq!(settings.custom_providers[0].api, "openai-completions");
2549        assert_eq!(settings.custom_providers[1].name, "zai");
2550        assert_eq!(settings.custom_providers[1].api, "openai-responses");
2551    }
2552
2553    #[test]
2554    fn test_custom_provider_json_deserialize() {
2555        let json_content = r#"{
2556            "custom_providers": [
2557                {
2558                    "name": "minimax",
2559                    "base_url": "https://api.minimax.chat/v1",
2560                    "api_key_env": "MINIMAX_API_KEY",
2561                    "api": "openai-completions"
2562                }
2563            ]
2564        }"#;
2565        let settings: Settings = serde_json::from_str(json_content).unwrap();
2566        assert_eq!(settings.custom_providers.len(), 1);
2567        assert_eq!(settings.custom_providers[0].name, "minimax");
2568    }
2569
2570    #[test]
2571    fn test_custom_provider_toml_roundtrip() {
2572        let mut settings = Settings::default();
2573        settings.custom_providers.push(super::CustomProvider {
2574            name: "test".to_string(),
2575            base_url: "https://api.test.com/v1".to_string(),
2576            api_key_env: "TEST_API_KEY".to_string(),
2577            api: "openai-completions".to_string(),
2578        });
2579
2580        let toml_str = toml::to_string_pretty(&settings).unwrap();
2581        let parsed: Settings = toml::from_str(&toml_str).unwrap();
2582        assert_eq!(parsed.custom_providers.len(), 1);
2583        assert_eq!(parsed.custom_providers[0].name, "test");
2584        assert_eq!(
2585            parsed.custom_providers[0].base_url,
2586            "https://api.test.com/v1"
2587        );
2588    }
2589
2590    #[test]
2591    fn test_custom_provider_defaults_empty() {
2592        let settings = Settings::default();
2593        assert!(settings.custom_providers.is_empty());
2594    }
2595
2596    #[test]
2597    fn test_custom_provider_layer_file() {
2598        let base = Settings::default();
2599
2600        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2601        let toml_content = r#"
2602[[custom_providers]]
2603name = "my-provider"
2604base_url = "https://api.my-provider.com/v1"
2605api_key_env = "MY_PROVIDER_API_KEY"
2606"#;
2607        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2608
2609        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2610        assert_eq!(merged.custom_providers.len(), 1);
2611        assert_eq!(merged.custom_providers[0].name, "my-provider");
2612        // Default api value
2613        assert_eq!(merged.custom_providers[0].api, "openai-completions");
2614    }
2615}