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