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