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