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