Skip to main content

oxicode/store/
settings.rs

1//! Settings management for oxicode CLI
2//!
3//! Settings are loaded in layers (later layers override earlier):
4//! 1. Built-in defaults
5//! 2. Global config: `~/.oxicode/settings.toml`
6//! 3. Project config: `.oxicode/settings.toml` (walked up to repo root)
7//! 4. Environment variables (`OXICODE_*` 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 (`oxicode-cli/src/store/`) depend on the UI layer
14// (`oxicode_tui`). The proper fix is to store only a discriminant
15// (`"unicode" | "ascii" | "nerd"`) here and let `oxicode_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 crate::symbols::GlyphSet;
22use anyhow::{Context, Result};
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 oxicode settings.
46/// Keep: reserved for future env-based config loading (e.g. OXICODE_API_KEY).
47#[allow(dead_code)]
48const ENV_PREFIX: &str = "OXICODE_";
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 `~/.oxicode/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
100pub(crate) fn 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 `oxicode_vtui::theme` (e.g. "oxi", "oxide-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: `~/.oxicode/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 `oxicode 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. Declared here for config persistence; not currently consumed by the `tui_vt` host loop.
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: `~/.oxicode/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 [`oxicode_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    /// `oxicode_agent::advisor` engine wired into `AgentSession`.
346    #[serde(default)]
347    pub advisor: AdvisorSettings,
348
349    // ── Hooks (port 16) ───────────────────────────────────────────
350    /// User-configured event→shell-command hooks. Loaded from the
351    /// `[[hooks]]` array in settings.toml. Project hooks are gated by
352    /// the first-run approval (see `store/hook_approval.rs`).
353    #[serde(default)]
354    pub hooks: Vec<oxicode_sdk::ports::HookSpec>,
355}
356
357/// Advisor subsystem settings — a read-only reviewer that shadows the primary
358/// agent and surfaces advice (`nit`/`concern`/`blocker`). All default OFF;
359/// the advisor is opt-in (set `enabled = true` in `[advisor]`).
360///
361/// Ported from omp's `advisor.*` settings (advisor.enabled /
362/// advisor.syncBacklog / advisor.immuneTurns).
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct AdvisorSettings {
365    /// Master switch. Default OFF.
366    #[serde(default = "default_false")]
367    pub enabled: bool,
368    /// Sync-backlog barrier: pause the primary when the advisor falls this many
369    /// turns behind, or `"off"` to never block. omp `advisor.syncBacklog`.
370    /// Default `"off"`.
371    #[serde(default = "default_advisor_sync_backlog")]
372    pub sync_backlog: String,
373    /// Post-interrupt immune-turn cooldown: after a `concern`/`blocker` steers
374    /// in, downgrade further `concern`/`blocker` notes to asides for this many
375    /// turns (prevents advice storms). omp `advisor.immuneTurns`. Default 0.
376    #[serde(default)]
377    pub immune_turns: u64,
378}
379
380impl Default for AdvisorSettings {
381    fn default() -> Self {
382        Self {
383            enabled: false,
384            sync_backlog: default_advisor_sync_backlog(),
385            immune_turns: 0,
386        }
387    }
388}
389
390fn default_advisor_sync_backlog() -> String {
391    "off".to_string()
392}
393
394fn default_theme() -> String {
395    "default".to_string()
396}
397
398fn default_thinking_level() -> ThinkingLevel {
399    ThinkingLevel::Medium
400}
401
402fn default_session_history_size() -> usize {
403    100
404}
405
406fn default_true() -> bool {
407    true
408}
409
410fn default_false() -> bool {
411    false
412}
413
414fn default_ttsr_mode() -> String {
415    "prose_only".to_string()
416}
417
418fn default_tool_timeout() -> u64 {
419    120
420}
421
422fn default_memory_backend() -> Option<String> {
423    None
424}
425
426fn default_embedding_provider() -> String {
427    "none".to_string()
428}
429
430fn default_embedding_model() -> String {
431    String::new()
432}
433
434fn default_embedding_api_key_env() -> String {
435    "OPENAI_API_KEY".to_string()
436}
437
438impl Default for Settings {
439    fn default() -> Self {
440        Self {
441            version: SETTINGS_VERSION,
442            thinking_level: ThinkingLevel::Medium,
443            theme: default_theme(),
444            glyph_set: GlyphSet::default(),
445            last_used_model: None,
446            last_used_provider: None,
447            default_model: None,
448            default_provider: None,
449            max_tokens: None,
450            temperature: None,
451            default_temperature: None,
452            max_response_tokens: None,
453            session_history_size: default_session_history_size(),
454            session_dir: None,
455            extensions_enabled: true,
456            auto_compaction: true,
457            disabled_tools: Vec::new(),
458            tool_timeout_seconds: default_tool_timeout(),
459            ask_timeout_secs: 0,
460            extensions: Vec::new(),
461            skills: Vec::new(),
462            prompts: Vec::new(),
463            themes: Vec::new(),
464            custom_providers: Vec::new(),
465            dynamic_models: HashMap::new(),
466            keybindings: HashMap::new(),
467            edit_format: EditFormat::default(),
468            memory_enabled: false,
469            memory_db_path: None,
470            mnemopi_engine: false,
471            memory_backend: default_memory_backend(),
472            memory_llm_extract: false,
473            memory_llm_extract_model: String::new(),
474            embedding_provider: default_embedding_provider(),
475            embedding_base_url: None,
476            embedding_api_key_env: default_embedding_api_key_env(),
477            embedding_model: default_embedding_model(),
478            todo_panel_enabled: true,
479            agent_hub_enabled: true,
480            snapcompact_enabled: false,
481            advisor: AdvisorSettings::default(),
482            mermaid_render_enabled: true,
483            commit_tool_enabled: false,
484            ttsr_enabled: false,
485            ttsr_interrupt_mode: default_ttsr_mode(),
486            model_roles: HashMap::new(),
487            hooks: Vec::new(),
488        }
489    }
490}
491
492impl Settings {
493    // ── Paths ────────────────────────────────────────────────────────
494
495    /// Get the global settings directory path (`~/.oxicode`).
496    pub fn settings_dir() -> Result<PathBuf> {
497        let base = dirs::home_dir().context("Cannot determine home directory")?;
498        Ok(base.join(".oxicode"))
499    }
500
501    /// Get the global settings TOML file path (`~/.oxicode/settings.toml`).
502    pub fn settings_toml_path() -> Result<PathBuf> {
503        Ok(Self::settings_dir()?.join("settings.toml"))
504    }
505
506    /// Get the global settings JSON file path (`~/.oxicode/settings.json`).
507    pub fn settings_json_path() -> Result<PathBuf> {
508        Ok(Self::settings_dir()?.join("settings.json"))
509    }
510
511    /// Get the global settings file path (JSON takes priority).
512    ///
513    /// Returns the path to the settings file that should be used.
514    /// If both JSON and TOML exist, JSON is returned (takes priority).
515    /// If only one exists, that path is returned.
516    /// If neither exists, returns the JSON path by default.
517    pub fn settings_path() -> Result<PathBuf> {
518        let json_path = Self::settings_json_path()?;
519        let toml_path = Self::settings_toml_path()?;
520
521        if json_path.exists() && toml_path.exists() {
522            // Both exist: JSON takes priority
523            tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
524            return Ok(json_path);
525        }
526
527        if json_path.exists() {
528            return Ok(json_path);
529        }
530
531        if toml_path.exists() {
532            return Ok(toml_path);
533        }
534
535        // Neither exists: default to JSON
536        Ok(json_path)
537    }
538
539    /// Get the effective settings file path, preferring the specified format.
540    ///
541    /// If `prefer_json` is true, checks JSON first; otherwise checks TOML first.
542    /// Returns the first existing file, or the preferred path if neither exists.
543    pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
544        let json_path = Self::settings_json_path()?;
545        let toml_path = Self::settings_toml_path()?;
546
547        let (primary, secondary) = if prefer_json {
548            (&json_path, &toml_path)
549        } else {
550            (&toml_path, &json_path)
551        };
552
553        if primary.exists() {
554            return Ok(primary.clone());
555        }
556
557        if secondary.exists() {
558            return Ok(secondary.clone());
559        }
560
561        // Neither exists: return preferred path
562        Ok(primary.clone())
563    }
564
565    /// Detect the settings file format from its path.
566    pub fn detect_format(path: &Path) -> SettingsFormat {
567        match path.extension().and_then(|e| e.to_str()) {
568            Some("json") => SettingsFormat::Json,
569            Some("toml") => SettingsFormat::Toml,
570            _ => SettingsFormat::Json, // Default to JSON for unknown extensions
571        }
572    }
573
574    /// Get the project-local settings file path.
575    ///
576    /// Searches for `.oxicode/settings.json` first, then `.oxicode/settings.toml`.
577    /// Returns the first one found, or None if neither exists.
578    pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
579        let mut dir = start_dir.to_path_buf();
580        loop {
581            // Check JSON first (priority), then TOML
582            let json_candidate = dir.join(".oxicode").join("settings.json");
583            if json_candidate.exists() {
584                return Some(json_candidate);
585            }
586
587            let toml_candidate = dir.join(".oxicode").join("settings.toml");
588            if toml_candidate.exists() {
589                return Some(toml_candidate);
590            }
591
592            if !dir.pop() {
593                return None;
594            }
595        }
596    }
597
598    /// Resolve the effective session directory.
599    ///
600    /// Priority: `session_dir` field → `~/.oxicode/sessions`.
601    pub fn effective_session_dir(&self) -> Result<PathBuf> {
602        if let Some(ref dir) = self.session_dir {
603            return Ok(dir.clone());
604        }
605        Ok(Self::settings_dir()?.join("sessions"))
606    }
607
608    // ── Loading ──────────────────────────────────────────────────────
609
610    /// Load settings, applying all layers:
611    ///
612    /// 1. Built-in defaults
613    /// 2. Global `~/.oxicode/settings.toml`
614    /// 3. Project `.oxicode/settings.toml`
615    /// 4. Environment variable overrides
616    ///
617    /// # Examples
618    ///
619    /// ```ignore
620    /// use oxicode_cli::Settings;
621    ///
622    /// let settings = Settings::load().expect("Failed to load settings");
623    /// println!("Using model: {}", settings.effective_model(None));
624    /// ```
625    pub fn load() -> Result<Self> {
626        Self::load_from_cwd()
627    }
628
629    /// Load settings with an explicit working directory for project config discovery.
630    ///
631    /// Always layers the global config from `Self::settings_path()` when it
632    /// exists. Use [`Settings::load_from_with`] to inject a custom global
633    /// path (e.g. for tests or portable mode).
634    pub fn load_from(dir: &std::path::Path) -> Result<Self> {
635        Self::load_from_with(dir, None)
636    }
637
638    /// Load settings with an explicit project directory and an optional
639    /// global settings path override.
640    ///
641    /// Layering order:
642    /// 1. Defaults
643    /// 2. Global config from `global_override` if `Some`, else from
644    ///    `Self::settings_path()` if it exists.
645    /// 3. Project config (`<dir>/.oxicode/settings.{toml,json}`).
646    /// 4. Environment variable overrides.
647    /// 5. Migration.
648    /// 6. TUI language policy validation.
649    ///
650    /// Passing `global_override = None` keeps the default behavior of
651    /// reading the user's real `~/.oxicode/settings.{toml,json}`. Tests pass
652    /// `Some(custom_path)` or rely on the real path being absent to get
653    /// pure defaults. (The test suite uses `Some(specific_path)` semantics
654    /// by passing a temp path; passing `None` is also valid for "skip the
655    /// global layer entirely".)
656    pub fn load_from_with(
657        dir: &std::path::Path,
658        global_override: Option<&std::path::Path>,
659    ) -> Result<Self> {
660        // 1. Start from defaults
661        let mut settings = Settings::default();
662
663        // 2. Layer global config (override takes precedence; None = use real
664        //    `~/.oxicode/settings.*` if present)
665        let resolved_global: Option<std::path::PathBuf> = match global_override {
666            Some(p) => Some(p.to_path_buf()),
667            None => Self::settings_path().ok(),
668        };
669        if let Some(ref gp) = resolved_global
670            && gp.exists()
671        {
672            settings = Self::layer_file(&settings, gp)?;
673        }
674
675        // 3. Layer project config
676        if let Some(project_path) = Self::find_project_settings(dir) {
677            settings = Self::layer_file(&settings, &project_path)?;
678        }
679
680        // 4. Layer environment variables
681        settings.apply_env();
682
683        // 5. Run migration if needed
684        settings = Self::migrate(settings)?;
685
686        // 5. Validate settings — placeholder for future validation
687
688        Ok(settings)
689    }
690
691    /// Convenience: load from current working directory.
692    pub fn load_from_cwd() -> Result<Self> {
693        let cwd = env::current_dir().context("Cannot determine current directory")?;
694        Self::load_from(&cwd)
695    }
696
697    /// Parse a settings file (TOML or JSON) and overlay its values onto `base`.
698    ///
699    /// The format is auto-detected based on the file extension.
700    /// Fields present in the file replace those in `base`; absent fields
701    /// are left untouched.
702    fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
703        let content = fs::read_to_string(path)
704            .with_context(|| format!("Failed to read settings from {}", path.display()))?;
705
706        let format = Self::detect_format(path);
707        let overlay: serde_json::Value = match format {
708            SettingsFormat::Toml => {
709                let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
710                    format!("Failed to parse TOML settings from {}", path.display())
711                })?;
712                // Convert TOML to JSON Value for uniform merging
713                toml_value_to_json(toml_value)
714            }
715            SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
716                format!("Failed to parse JSON settings from {}", path.display())
717            })?,
718        };
719
720        // Re-serialize the base to JSON, merge with the overlay, then
721        // deserialize back. This gives correct "only override what's
722        // present" semantics.
723        let base_json =
724            serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
725
726        let merged = merge_json_values(base_json, overlay);
727        let result: Settings =
728            serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
729
730        Ok(result)
731    }
732
733    // ── Environment variables ────────────────────────────────────────
734
735    /// Apply environment variable overrides in-place.
736    ///
737    /// DEPRECATED: Environment variable overrides are being phased out in favor
738    /// of file-based configuration (`~/.oxicode/settings.toml`). This method is
739    /// kept for CI/CD compatibility but should not be relied upon for local
740    /// development. Use `oxicode config set` or `oxicode setup` instead.
741    ///
742    /// Supported variables (CI/CD only):
743    ///
744    /// | Env var                    | Setting                |
745    /// |---------------------------|------------------------|
746    /// | `OXICODE_MODEL`               | `default_model`        |
747    /// | `OXICODE_PROVIDER`            | `default_provider`     |
748    /// | `OXICODE_THINKING`            | `thinking_level`       |
749    /// | `OXICODE_THEME`               | `theme`                |
750    /// | `OXICODE_MAX_TOKENS`          | `max_tokens`           |
751    /// | `OXICODE_TEMPERATURE`         | `default_temperature`  |
752    /// | `OXICODE_SESSION_DIR`         | `session_dir`          |
753    /// | `OXICODE_EXTENSIONS_ENABLED`  | `extensions_enabled`   |
754    /// | `OXICODE_AUTO_COMPACTION`     | `auto_compaction`      |
755    /// | `OXICODE_TOOL_TIMEOUT`        | `tool_timeout_seconds` |
756    /// | `OXICODE_DISABLED_TOOLS`      | `disabled_tools`       |
757    #[allow(dead_code)]
758    pub fn apply_env(&mut self) {
759        // No-op: environment variable overrides are disabled.
760        // All configuration should come from settings.toml / settings.json.
761        // This method is kept for backward compatibility but does nothing.
762    }
763
764    /// Build a `Settings` instance from **only** environment variables
765    /// (all other fields stay at defaults).
766    ///
767    /// DEPRECATED: Returns defaults since env overrides are disabled.
768    /// Use `Settings::load()` to load from settings.toml instead.
769    #[allow(dead_code)]
770    pub fn from_env() -> Self {
771        Self::default()
772    }
773
774    // ── Persistence ──────────────────────────────────────────────────
775
776    /// Save settings to the global config file.
777    ///
778    /// Uses the format of the existing file if present, otherwise saves as JSON.
779    /// Preserves backward compatibility with existing TOML files.
780    pub fn save(&self) -> Result<()> {
781        let dir = Self::settings_dir()?;
782        let path = Self::settings_path()?;
783
784        if !dir.exists() {
785            fs::create_dir_all(&dir).with_context(|| {
786                format!("Failed to create settings directory {}", dir.display())
787            })?;
788        }
789
790        let format = Self::detect_format(&path);
791        let content = Self::serialize_for_format(self, format)?;
792
793        // Atomic write: write to temp file first, then rename
794        let tmp_path = path.with_extension("tmp");
795        fs::write(&tmp_path, &content)
796            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
797        fs::rename(&tmp_path, &path)
798            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
799
800        Ok(())
801    }
802
803    /// Save settings to a specific path, using the format determined by the file extension.
804    pub fn save_to(&self, path: &Path) -> Result<()> {
805        if let Some(parent) = path.parent()
806            && !parent.exists()
807        {
808            fs::create_dir_all(parent)
809                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
810        }
811
812        let format = Self::detect_format(path);
813        let content = Self::serialize_for_format(self, format)?;
814
815        // Atomic write
816        let tmp_path = path.with_extension("tmp");
817        fs::write(&tmp_path, &content)
818            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
819        fs::rename(&tmp_path, path)
820            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
821
822        Ok(())
823    }
824
825    /// Save settings to the project-local config file.
826    ///
827    /// Uses the format of the existing file if present, otherwise saves as JSON.
828    pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
829        let dir = project_dir.join(".oxicode");
830
831        if !dir.exists() {
832            fs::create_dir_all(&dir).with_context(|| {
833                format!(
834                    "Failed to create project settings directory {}",
835                    dir.display()
836                )
837            })?;
838        }
839
840        // Check if a settings file already exists in project
841        let json_path = dir.join("settings.json");
842        let toml_path = dir.join("settings.toml");
843
844        let path = if json_path.exists() {
845            &json_path
846        } else if toml_path.exists() {
847            &toml_path
848        } else {
849            // Default to JSON for new files
850            &json_path
851        };
852
853        let format = Self::detect_format(path);
854        let content = Self::serialize_for_format(self, format)?;
855
856        // Atomic write
857        let tmp_path = path.with_extension("tmp");
858        fs::write(&tmp_path, &content)
859            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
860        fs::rename(&tmp_path, path)
861            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
862
863        Ok(())
864    }
865
866    /// Serialize settings to a string in the specified format.
867    pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
868        match format {
869            SettingsFormat::Toml => {
870                toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
871            }
872            SettingsFormat::Json => serde_json::to_string_pretty(settings)
873                .context("Failed to serialize settings to JSON"),
874        }
875    }
876
877    /// Parse settings from a string in the specified format.
878    pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
879        match format {
880            SettingsFormat::Toml => {
881                toml::from_str(content).context("Failed to parse TOML settings")
882            }
883            SettingsFormat::Json => {
884                serde_json::from_str(content).context("Failed to parse JSON settings")
885            }
886        }
887    }
888
889    // ── CLI overrides ────────────────────────────────────────────────
890
891    /// Merge with CLI arguments (CLI takes precedence).
892    ///
893    /// # Arguments
894    ///
895    /// * `model` — CLI-specified model override
896    /// * `provider` — CLI-specified provider override
897    pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
898        if let Some(m) = model {
899            self.last_used_model = Some(m);
900        }
901        if let Some(p) = provider {
902            self.last_used_provider = Some(p);
903        }
904    }
905
906    /// Get the effective model ID (provider/model format).
907    /// Returns None if no model is configured.
908    pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
909        cli_model.map(String::from).or_else(|| {
910            // Reconstruct full model ID from separate fields.
911            // Handles both cases:
912            //   - last_used_model = "anthropic/claude-sonnet-4" (full ID, stored by save_last_used)
913            //   - last_used_model = "claude-sonnet-4" + last_used_provider = "anthropic" (split)
914            let model = self.last_used_model.as_ref()?;
915            if model.contains('/') {
916                // Already a full model ID
917                Some(model.clone())
918            } else if let Some(ref provider) = self.last_used_provider {
919                // Reconstruct from separate fields
920                Some(format!("{}/{}", provider, model))
921            } else {
922                Some(model.clone())
923            }
924        })
925    }
926
927    /// Get the effective provider.
928    /// Returns None if no provider is configured.
929    pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
930        cli_provider
931            .map(String::from)
932            .or_else(|| self.last_used_provider.clone())
933    }
934
935    /// Get the effective temperature, preferring `default_temperature` (f64)
936    /// over `temperature` (f32), falling back to `None`.
937    pub fn effective_temperature(&self) -> Option<f64> {
938        self.default_temperature
939            .or(self.temperature.map(|t| t as f64))
940    }
941
942    /// Get the effective max tokens, preferring `max_response_tokens` (usize)
943    /// over `max_tokens` (u32), falling back to `None`.
944    pub fn effective_max_tokens(&self) -> Option<usize> {
945        self.max_response_tokens
946            .or(self.max_tokens.map(|t| t as usize))
947    }
948
949    // ── Theme persistence ─────────────────────────────────────────────
950
951    /// Save the last used model/provider and persist to disk.
952    ///
953    /// Splits the model_id on first `/` to store provider and model separately.
954    pub fn save_last_used(model_id: &str) {
955        if let Ok(mut settings) = Self::load() {
956            if let Some((provider, model)) = model_id.split_once('/') {
957                settings.last_used_provider = Some(provider.to_string());
958                settings.last_used_model = Some(model.to_string());
959            } else {
960                settings.last_used_model = Some(model_id.to_string());
961            }
962            let _ = settings.save();
963        }
964    }
965
966    /// Save the current theme to settings and persist to disk.
967    pub fn save_theme(&mut self, name: &str) -> Result<()> {
968        self.theme = name.to_string();
969        self.save()
970    }
971
972    /// Get the theme name from settings, returning a default if not set.
973    pub fn get_theme_name(&self) -> String {
974        if self.theme.is_empty() || self.theme == "default" {
975            "oxi".to_string()
976        } else {
977            self.theme.clone()
978        }
979    }
980
981    // ── Migration ────────────────────────────────────────────────────
982
983    /// Migrate settings from an older format version to the current one.
984    ///
985    /// Currently handles:
986    /// - Version 0 → Version 6 (multi-step)
987    /// - Version 1 → Version 6 (multi-step)
988    /// - Version 2 → Version 6 (multi-step)
989    /// - Version 3 → Version 4 (default_model → last_used_model)
990    /// - Version 7 → Version 8 (edit_format field added —
991    ///   `#[serde(default)]` fills with EditFormat::StrReplace)
992    /// - Version 8 → Version 9 (model_roles field added — no value
993    ///   migration, `#[serde(default)]` fills with an empty map)
994    fn migrate(settings: Settings) -> Result<Settings> {
995        let mut settings = settings;
996
997        match settings.version {
998            SETTINGS_VERSION => {
999                // Already current — nothing to do.
1000            }
1001            0 => {
1002                // Version 0 = pre-versioning config.
1003                // Add any defaults that were introduced in version 1.
1004                if settings.tool_timeout_seconds == 0 {
1005                    settings.tool_timeout_seconds = default_tool_timeout();
1006                }
1007                settings.version = SETTINGS_VERSION;
1008
1009                tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1010            }
1011            1 | 2 => {
1012                // Version 1/2 → 10: dynamic_models field added + model/provider split.
1013                // The v3 → v4 default_model → last_used_model split doesn't apply
1014                // here (no default_model in v1/v2). `#[serde(default)]` fills missing fields.
1015                settings.version = SETTINGS_VERSION;
1016                tracing::info!(
1017                    "Migrated settings from version {} to {}",
1018                    settings.version,
1019                    SETTINGS_VERSION
1020                );
1021            }
1022            3 => {
1023                // Version 3 → 4 step happens inline: migrate default_model → last_used_model.
1024                // Then collapse to current version.
1025                if let Some(model) = settings.default_model.take() {
1026                    if let Some((provider, model_name)) = model.split_once('/') {
1027                        settings.last_used_provider = Some(provider.to_string());
1028                        settings.last_used_model = Some(model_name.to_string());
1029                    } else {
1030                        settings.last_used_model = Some(model);
1031                    }
1032                }
1033                settings.version = SETTINGS_VERSION;
1034                tracing::info!(
1035                    "Migrated settings from version 3 to {} (default_model → last_used_model)",
1036                    SETTINGS_VERSION
1037                );
1038            }
1039            4 => {
1040                // Version 4 → 10: `#[serde(default)]` fills missing fields.
1041                settings.version = SETTINGS_VERSION;
1042                tracing::info!("Migrated settings from version 4 to {}", SETTINGS_VERSION);
1043            }
1044            5 => {
1045                // Version 5 → 10: `#[serde(default)]` fills missing fields.
1046                settings.version = SETTINGS_VERSION;
1047                tracing::info!("Migrated settings from version 5 to {}", SETTINGS_VERSION);
1048            }
1049            6 => {
1050                // Version 6 → 7: edit_format field added.
1051                // `#[serde(default)]` fills with EditFormat::StrReplace (default).
1052                settings.version = SETTINGS_VERSION;
1053                tracing::info!(
1054                    "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1055                    SETTINGS_VERSION
1056                );
1057            }
1058            7 => {
1059                // Version 7 → 8: glyph_set field added.
1060                // `#[serde(default)]` fills with GlyphSet::Unicode (default).
1061                settings.version = SETTINGS_VERSION;
1062                tracing::info!(
1063                    "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1064                    SETTINGS_VERSION
1065                );
1066            }
1067            8 => {
1068                // Version 8 → 9: model_roles field added (ported from omp).
1069                // No value migration — `#[serde(default)]` fills an empty map.
1070                settings.version = SETTINGS_VERSION;
1071                tracing::info!(
1072                    "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1073                    SETTINGS_VERSION
1074                );
1075            }
1076            v if v > SETTINGS_VERSION => {
1077                // Future version — we don't know how to downgrade.
1078                anyhow::bail!(
1079                    "Settings version {} is newer than supported version {}. \
1080                     Please update oxicode.",
1081                    v,
1082                    SETTINGS_VERSION
1083                );
1084            }
1085            v => {
1086                // Unknown old version — best-effort migration.
1087                tracing::warn!(
1088                    "Unknown settings version {}, attempting migration to {}",
1089                    v,
1090                    SETTINGS_VERSION
1091                );
1092                settings.version = SETTINGS_VERSION;
1093            }
1094        }
1095
1096        Ok(settings)
1097    }
1098}
1099
1100// ── Settings format detection ──────────────────────────────────────
1101
1102/// Supported settings file formats.
1103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1104pub enum SettingsFormat {
1105    /// JSON format.
1106    #[default]
1107    Json,
1108    /// TOML format.
1109    Toml,
1110}
1111
1112impl SettingsFormat {
1113    /// Get the file extension for this format.
1114    pub fn extension(&self) -> &'static str {
1115        match self {
1116            SettingsFormat::Json => "json",
1117            SettingsFormat::Toml => "toml",
1118        }
1119    }
1120}
1121
1122// ── JSON/TOML conversion helpers ────────────────────────────────────
1123
1124/// Convert a TOML Value to a serde_json::Value.
1125fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1126    match toml {
1127        toml::Value::String(s) => serde_json::Value::String(s),
1128        toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1129        toml::Value::Float(f) => serde_json::Number::from_f64(f)
1130            .map(serde_json::Value::Number)
1131            .unwrap_or(serde_json::Value::Null),
1132        toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1133        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1134        toml::Value::Array(arr) => {
1135            serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1136        }
1137        toml::Value::Table(table) => {
1138            let obj = table
1139                .into_iter()
1140                .map(|(k, v)| (k, toml_value_to_json(v)))
1141                .collect();
1142            serde_json::Value::Object(obj)
1143        }
1144    }
1145}
1146
1147/// Deep merge two JSON values. The second value overrides the first.
1148fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1149    match (base, override_) {
1150        // If either is not an object, the override wins
1151        (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1152            let mut result = base_map;
1153            for (key, override_value) in override_map {
1154                let base_value = result.remove(&key);
1155                let merged = match base_value {
1156                    Some(base_v) => merge_json_values(base_v, override_value),
1157                    None => override_value,
1158                };
1159                result.insert(key, merged);
1160            }
1161            serde_json::Value::Object(result)
1162        }
1163        // Override wins for non-objects
1164        (_, override_) => override_,
1165    }
1166}
1167
1168/// Parse a thinking level from a string.
1169pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1170    match s.to_lowercase().as_str() {
1171        "off" | "none" => Some(ThinkingLevel::Off),
1172        "minimal" => Some(ThinkingLevel::Minimal),
1173        "low" => Some(ThinkingLevel::Low),
1174        "medium" | "standard" => Some(ThinkingLevel::Medium),
1175        "high" | "thorough" => Some(ThinkingLevel::High),
1176        "xhigh" => Some(ThinkingLevel::XHigh),
1177        _ => None,
1178    }
1179}
1180
1181/// Parse a boolean-like string (`"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"`).
1182#[allow(dead_code)]
1183fn parse_boolish(s: &str) -> Result<bool> {
1184    match s.to_lowercase().as_str() {
1185        "true" | "1" | "yes" | "on" => Ok(true),
1186        "false" | "0" | "no" | "off" => Ok(false),
1187        _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1188    }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194    use std::io::Write as IoWrite;
1195    use std::sync::Mutex;
1196
1197    /// Global lock to serialize all tests that manipulate process-wide env vars.
1198    #[allow(dead_code)] // held implicitly via guard pattern; not all tests acquire it
1199    static ENV_LOCK: Mutex<()> = Mutex::new(());
1200
1201    /// RAII guard that removes listed env vars on creation and restores them on drop.
1202    /// This prevents parallel test races where one test sets an env var that leaks into another.
1203    struct EnvGuard {
1204        saved: Vec<(String, Option<String>)>,
1205    }
1206
1207    impl EnvGuard {
1208        fn new(vars: &[&str]) -> Self {
1209            let saved = vars
1210                .iter()
1211                .map(|&name| {
1212                    let old = env::var(name).ok();
1213                    // SAFETY: test-only; the ENV_LOCK mutex serializes access.
1214                    unsafe { env::remove_var(name) };
1215                    (name.to_string(), old)
1216                })
1217                .collect();
1218            Self { saved }
1219        }
1220    }
1221
1222    impl Drop for EnvGuard {
1223        fn drop(&mut self) {
1224            for (name, old) in self.saved.drain(..) {
1225                match old {
1226                    // SAFETY: test-only; the ENV_LOCK mutex serializes access.
1227                    Some(val) => unsafe { env::set_var(&name, val) },
1228                    None => unsafe { env::remove_var(&name) },
1229                }
1230            }
1231        }
1232    }
1233
1234    // ── Struct tests ─────────────────────────────────────────────────
1235
1236    #[test]
1237    fn test_default_settings() {
1238        let settings = Settings::default();
1239        assert_eq!(settings.version, SETTINGS_VERSION);
1240        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1241        assert_eq!(settings.theme, "default");
1242        assert!(settings.last_used_model.is_none());
1243        assert!(settings.last_used_provider.is_none());
1244        assert!(settings.extensions_enabled);
1245        assert!(settings.auto_compaction);
1246        assert_eq!(settings.tool_timeout_seconds, 120);
1247    }
1248
1249    #[test]
1250    fn test_merge_cli() {
1251        let mut settings = Settings::default();
1252        settings.last_used_model = Some("gpt-4o".to_string());
1253
1254        settings.merge_cli(Some("claude".to_string()), None);
1255        assert_eq!(settings.last_used_model, Some("claude".to_string()));
1256
1257        settings.merge_cli(None, Some("google".to_string()));
1258        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1259    }
1260
1261    // ── Layered loading ──────────────────────────────────────────────
1262
1263    #[test]
1264    fn test_layer_file_overrides() {
1265        let base = Settings::default();
1266
1267        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1268        let toml_content = r#"
1269last_used_model = "openai/gpt-4o"
1270theme = "dracula"
1271"#;
1272        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1273
1274        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1275        assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1276        assert_eq!(merged.theme, "dracula");
1277        // Unchanged fields retain defaults
1278        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1279        assert!(merged.extensions_enabled);
1280    }
1281
1282    #[test]
1283    fn test_layer_file_preserves_unset() {
1284        let mut base = Settings::default();
1285        base.last_used_provider = Some("deepseek".to_string());
1286
1287        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1288        // Only override theme — provider should remain
1289        let toml_content = "theme = \"monokai\"\n";
1290        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1291
1292        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1293        assert_eq!(merged.theme, "monokai");
1294        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1295    }
1296
1297    #[test]
1298    fn test_load_from_dir_with_project_config() {
1299        let _guard = EnvGuard::new(&[
1300            "OXICODE_MODEL",
1301            "OXICODE_PROVIDER",
1302            "OXICODE_THEME",
1303            "OXICODE_TOOL_TIMEOUT",
1304            "OXICODE_TEMPERATURE",
1305            "OXICODE_MAX_TOKENS",
1306            "OXICODE_SESSION_DIR",
1307            "OXICODE_EXTENSIONS_ENABLED",
1308        ]);
1309        let tmp = tempfile::tempdir().unwrap();
1310        let oxicode_dir = tmp.path().join(".oxicode");
1311        fs::create_dir_all(&oxicode_dir).unwrap();
1312        let settings_path = oxicode_dir.join("settings.toml");
1313        // Write v3 format: default_model contains "provider/model"
1314        fs::write(
1315            &settings_path,
1316            "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1317        )
1318        .unwrap();
1319
1320        let settings = Settings::load_from(tmp.path()).unwrap();
1321        // Migration moves default_model → last_used_model
1322        assert_eq!(
1323            settings.last_used_model,
1324            Some("gemini-2.0-flash".to_string())
1325        );
1326        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1327    }
1328
1329    #[test]
1330    fn test_load_from_dir_no_config() {
1331        // Clean env vars that load_from() reads via apply_env()
1332        let _guard = EnvGuard::new(&[
1333            "OXICODE_MODEL",
1334            "OXICODE_PROVIDER",
1335            "OXICODE_THEME",
1336            "OXICODE_TOOL_TIMEOUT",
1337            "OXICODE_TEMPERATURE",
1338            "OXICODE_MAX_TOKENS",
1339            "OXICODE_SESSION_DIR",
1340            "OXICODE_EXTENSIONS_ENABLED",
1341        ]);
1342        let tmp = tempfile::tempdir().unwrap();
1343        // Pass a nonexistent global path so the real `~/.oxicode/settings.*`
1344        // never leaks into the test. (`Settings::load_from` reads the
1345        // real global config when present, which is what made this test
1346        // fail when the user's global set `thinking_level = "high"`.)
1347        let global = tmp.path().join("nonexistent-settings.json");
1348        let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1349        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1350    }
1351    #[test]
1352    fn test_from_env() {
1353        // NOTE: Environment variable overrides are disabled.
1354        // from_env() returns defaults only.
1355        let _guard = EnvGuard::new(&[
1356            // no env vars to clear
1357            "OXICODE_MODEL",
1358            "OXICODE_THEME",
1359            "OXICODE_TOOL_TIMEOUT",
1360            "OXICODE_PROVIDER",
1361            "OXICODE_DEFAULT_MODEL",
1362        ]);
1363
1364        let settings = Settings::from_env();
1365        // All fields should be at defaults since env overrides are disabled
1366        assert_eq!(settings.last_used_model, None);
1367        assert_eq!(settings.theme, "default");
1368        assert_eq!(settings.tool_timeout_seconds, 120);
1369    }
1370
1371    #[test]
1372    fn test_apply_env_boolish() {
1373        // NOTE: Environment variable overrides are disabled.
1374        // apply_env() is a no-op.
1375        let _guard = EnvGuard::new(&["OXICODE_EXTENSIONS_ENABLED"]);
1376        unsafe { env::set_var("OXICODE_EXTENSIONS_ENABLED", "0") };
1377
1378        let mut settings = Settings::default();
1379        settings.apply_env();
1380        // Since env overrides are disabled, values stay at defaults
1381        assert!(settings.extensions_enabled); // default is true
1382    }
1383
1384    #[test]
1385    fn test_apply_env_temperature() {
1386        // NOTE: Environment variable overrides are disabled.
1387        let _guard = EnvGuard::new(&["OXICODE_TEMPERATURE"]);
1388        unsafe { env::set_var("OXICODE_TEMPERATURE", "0.7") };
1389
1390        let mut settings = Settings::default();
1391        settings.apply_env();
1392        // Since env overrides are disabled, temperature stays at None
1393        assert_eq!(settings.default_temperature, None);
1394    }
1395
1396    #[test]
1397    fn test_env_does_not_override_when_unset() {
1398        let _guard = EnvGuard::new(&[
1399            "OXICODE_MODEL",
1400            "OXICODE_PROVIDER",
1401            "OXICODE_THEME",
1402            "OXICODE_TEMPERATURE",
1403        ]);
1404        let settings = Settings::from_env();
1405        assert!(settings.last_used_model.is_none());
1406        assert!(settings.last_used_provider.is_none());
1407    }
1408
1409    #[test]
1410    fn test_parse_thinking_level() {
1411        assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1412        assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1413        assert_eq!(
1414            parse_thinking_level("MINIMAL"),
1415            Some(ThinkingLevel::Minimal)
1416        );
1417        assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1418        assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1419        assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1420        assert_eq!(
1421            parse_thinking_level("Standard"),
1422            Some(ThinkingLevel::Medium)
1423        );
1424        assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1425        assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1426        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1427        assert_eq!(parse_thinking_level("invalid"), None);
1428    }
1429
1430    #[test]
1431    fn test_parse_boolish() {
1432        assert!(parse_boolish("true").unwrap());
1433        assert!(parse_boolish("1").unwrap());
1434        assert!(parse_boolish("yes").unwrap());
1435        assert!(parse_boolish("ON").unwrap());
1436        assert!(!parse_boolish("false").unwrap());
1437        assert!(!parse_boolish("0").unwrap());
1438        assert!(!parse_boolish("no").unwrap());
1439        assert!(!parse_boolish("OFF").unwrap());
1440        assert!(parse_boolish("maybe").is_err());
1441    }
1442
1443    // ── Effective accessors ──────────────────────────────────────────
1444
1445    #[test]
1446    fn test_effective_model_returns_last_used() {
1447        let mut settings = Settings::default();
1448        settings.last_used_model = Some("openai/gpt-4o".to_string());
1449        assert_eq!(
1450            settings.effective_model(None),
1451            Some("openai/gpt-4o".to_string())
1452        );
1453    }
1454
1455    #[test]
1456    fn test_effective_model_cli_overrides() {
1457        let mut settings = Settings::default();
1458        settings.last_used_model = Some("openai/gpt-4o".to_string());
1459        assert_eq!(
1460            settings.effective_model(Some("anthropic/claude-3")),
1461            Some("anthropic/claude-3".to_string())
1462        );
1463    }
1464
1465    #[test]
1466    fn test_effective_model_none_when_unset() {
1467        let settings = Settings::default();
1468        assert_eq!(settings.effective_model(None), None);
1469    }
1470
1471    #[test]
1472    fn test_effective_model_falls_back_to_last_used() {
1473        let mut settings = Settings::default();
1474        settings.last_used_model = Some("anthropic/claude-3".to_string());
1475        assert_eq!(
1476            settings.effective_model(None),
1477            Some("anthropic/claude-3".to_string())
1478        );
1479    }
1480
1481    #[test]
1482    fn test_effective_model_returns_none_when_nothing_set() {
1483        let settings = Settings::default();
1484        assert_eq!(settings.effective_model(None), None);
1485    }
1486
1487    #[test]
1488    fn test_effective_temperature_prefers_f64() {
1489        let mut settings = Settings::default();
1490        settings.temperature = Some(0.5);
1491        settings.default_temperature = Some(0.7);
1492        assert_eq!(settings.effective_temperature(), Some(0.7));
1493    }
1494
1495    #[test]
1496    fn test_effective_temperature_falls_back_to_f32() {
1497        let mut settings = Settings::default();
1498        settings.temperature = Some(0.5);
1499        assert_eq!(settings.effective_temperature(), Some(0.5));
1500    }
1501
1502    #[test]
1503    fn test_effective_max_tokens_prefers_usize() {
1504        let mut settings = Settings::default();
1505        settings.max_tokens = Some(1024);
1506        settings.max_response_tokens = Some(4096);
1507        assert_eq!(settings.effective_max_tokens(), Some(4096));
1508    }
1509
1510    #[test]
1511    fn test_effective_max_tokens_falls_back_to_u32() {
1512        let mut settings = Settings::default();
1513        settings.max_tokens = Some(1024);
1514        assert_eq!(settings.effective_max_tokens(), Some(1024));
1515    }
1516
1517    // ── Session dir ──────────────────────────────────────────────────
1518
1519    #[test]
1520    fn test_effective_session_dir_default() {
1521        let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1522        let settings = Settings::default();
1523        let dir = settings.effective_session_dir().unwrap();
1524        assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1525    }
1526
1527    #[test]
1528    fn test_effective_session_dir_from_field() {
1529        let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1530        let mut settings = Settings::default();
1531        settings.session_dir = Some(PathBuf::from("/tmp/oxicode-sessions"));
1532        assert_eq!(
1533            settings.effective_session_dir().unwrap(),
1534            PathBuf::from("/tmp/oxicode-sessions")
1535        );
1536    }
1537
1538    #[test]
1539    fn test_effective_session_dir_env_disabled() {
1540        // NOTE: Environment variable overrides are disabled.
1541        // OXICODE_SESSION_DIR is ignored; effective_session_dir() returns the field value (or default).
1542        let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1543        unsafe { env::set_var("OXICODE_SESSION_DIR", "/tmp/env-sessions") };
1544        let settings = Settings::default();
1545        // Env is ignored, so it should use the default path, not /tmp/env-sessions
1546        let dir = settings.effective_session_dir().unwrap();
1547        assert!(
1548            dir.ends_with("sessions"),
1549            "expected default sessions dir, got: {:?}",
1550            dir
1551        );
1552    }
1553
1554    // ── Migration ────────────────────────────────────────────────────
1555
1556    #[test]
1557    fn test_migration_v0_to_v1() {
1558        let mut settings = Settings::default();
1559        settings.version = 0;
1560        settings.tool_timeout_seconds = 0; // v0 might not have this field
1561
1562        let migrated = Settings::migrate(settings).unwrap();
1563        assert_eq!(migrated.version, SETTINGS_VERSION);
1564        assert_eq!(migrated.tool_timeout_seconds, 120);
1565    }
1566
1567    #[test]
1568    fn test_migration_already_current() {
1569        let settings = Settings::default();
1570        let migrated = Settings::migrate(settings).unwrap();
1571        assert_eq!(migrated.version, SETTINGS_VERSION);
1572    }
1573
1574    #[test]
1575    fn test_migration_v3_to_v4_splits_model() {
1576        let mut settings = Settings::default();
1577        settings.version = 3;
1578        settings.default_model = Some("openai/gpt-4o".to_string());
1579        settings.default_provider = None;
1580
1581        let migrated = Settings::migrate(settings).unwrap();
1582        assert_eq!(migrated.version, SETTINGS_VERSION);
1583        assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1584        assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1585    }
1586
1587    #[test]
1588    fn test_migration_v3_no_slash_keeps_model() {
1589        let mut settings = Settings::default();
1590        settings.version = 3;
1591        settings.default_model = Some("bare-model-name".to_string());
1592
1593        let migrated = Settings::migrate(settings).unwrap();
1594        assert_eq!(migrated.version, SETTINGS_VERSION);
1595        assert_eq!(
1596            migrated.last_used_model,
1597            Some("bare-model-name".to_string())
1598        );
1599    }
1600
1601    #[test]
1602    fn test_migration_future_version_fails() {
1603        let mut settings = Settings::default();
1604        settings.version = 9999;
1605        assert!(Settings::migrate(settings).is_err());
1606    }
1607
1608    #[test]
1609    fn test_default_glyph_set_is_unicode() {
1610        let settings = Settings::default();
1611        assert_eq!(
1612            settings.glyph_set,
1613            GlyphSet::Unicode,
1614            "glyph_set must default to Unicode"
1615        );
1616    }
1617
1618    #[test]
1619    fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1620        // v7 settings (no glyph_set field on disk) deserialize with the serde
1621        // default (Unicode) and migrate to v8.
1622        let mut settings = Settings::default();
1623        settings.version = 7;
1624        // Simulate a freshly-loaded v7 file: glyph_set unset → default.
1625        settings.glyph_set = GlyphSet::default();
1626
1627        let migrated = Settings::migrate(settings).unwrap();
1628        assert_eq!(migrated.version, SETTINGS_VERSION);
1629        assert_eq!(
1630            migrated.glyph_set,
1631            GlyphSet::Unicode,
1632            "v7 → v8 migration must default glyph_set to unicode"
1633        );
1634    }
1635
1636    #[test]
1637    fn test_glyph_set_persists_through_roundtrip() {
1638        // Direct TOML serialize → deserialize exercises the on-disk
1639        // snake_case form (`glyph_set = "nerd"`) without depending on
1640        // the layered `load_from` directory walk.
1641        let mut original = Settings::default();
1642        original.glyph_set = GlyphSet::Nerd;
1643        let content = toml::to_string_pretty(&original).unwrap();
1644        assert!(
1645            content.contains("glyph_set = \"nerd\""),
1646            "nerd preset must serialize to snake_case; got:\n{content}"
1647        );
1648        let loaded: Settings = toml::from_str(&content).unwrap();
1649        assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1650        // Unicode round-trips too.
1651        original.glyph_set = GlyphSet::Unicode;
1652        let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1653        assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1654    }
1655
1656    #[test]
1657    fn test_save_and_load_roundtrip() {
1658        let tmp = tempfile::tempdir().unwrap();
1659        let settings_path = tmp.path().join("settings.toml");
1660
1661        let mut original = Settings::default();
1662        original.last_used_model = Some("gpt-4o".to_string());
1663        original.last_used_provider = Some("openai".to_string());
1664        original.theme = "dracula".to_string();
1665        original.tool_timeout_seconds = 60;
1666
1667        // Serialize
1668        let content = toml::to_string_pretty(&original).unwrap();
1669        fs::write(&settings_path, &content).unwrap();
1670
1671        // Deserialize
1672        let loaded_content = fs::read_to_string(&settings_path).unwrap();
1673        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1674
1675        assert_eq!(loaded.last_used_model, original.last_used_model);
1676        assert_eq!(loaded.theme, original.theme);
1677        assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1678    }
1679
1680    #[test]
1681    fn test_toml_roundtrip_preserves_new_fields() {
1682        let mut settings = Settings::default();
1683        settings.default_temperature = Some(0.8);
1684        settings.max_response_tokens = Some(8192);
1685        settings.auto_compaction = false;
1686        settings.extensions_enabled = false;
1687        settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1688
1689        let toml_str = toml::to_string_pretty(&settings).unwrap();
1690        let parsed: Settings = toml::from_str(&toml_str).unwrap();
1691
1692        assert_eq!(parsed.default_temperature, Some(0.8));
1693        assert_eq!(parsed.max_response_tokens, Some(8192));
1694        assert!(!parsed.auto_compaction);
1695        assert!(!parsed.extensions_enabled);
1696        assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1697    }
1698
1699    // ── JSON format tests ──────────────────────────────────────────────
1700
1701    #[test]
1702    fn test_json_roundtrip() {
1703        let mut settings = Settings::default();
1704        settings.last_used_model = Some("gpt-4o".to_string());
1705        settings.last_used_provider = Some("openai".to_string());
1706        settings.theme = "dracula".to_string();
1707        settings.tool_timeout_seconds = 60;
1708        settings.default_temperature = Some(0.8);
1709        settings.max_response_tokens = Some(8192);
1710
1711        let json_str = serde_json::to_string_pretty(&settings).unwrap();
1712        let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1713
1714        assert_eq!(parsed.last_used_model, settings.last_used_model);
1715        assert_eq!(parsed.theme, settings.theme);
1716        assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1717        assert_eq!(parsed.default_temperature, settings.default_temperature);
1718        assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1719    }
1720
1721    #[test]
1722    fn test_json_serialize_for_format() {
1723        let mut settings = Settings::default();
1724        settings.last_used_model = Some("claude-3".to_string());
1725        settings.last_used_provider = Some("anthropic".to_string());
1726        settings.thinking_level = ThinkingLevel::Minimal;
1727
1728        let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1729        let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1730
1731        assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1732        assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1733    }
1734
1735    #[test]
1736    fn test_toml_serialize_for_format() {
1737        let mut settings = Settings::default();
1738        settings.last_used_model = Some("gemini-pro".to_string());
1739        settings.last_used_provider = Some("google".to_string());
1740        settings.thinking_level = ThinkingLevel::High;
1741
1742        let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1743        let parsed: Settings = toml::from_str(&toml_content).unwrap();
1744
1745        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1746        assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1747    }
1748
1749    #[test]
1750    fn test_parse_from_str_json() {
1751        let json_content = r#"{
1752            "last_used_model": "gpt-4",
1753            "last_used_provider": "openai",
1754            "theme": "nord",
1755            "tool_timeout_seconds": 90
1756        }"#;
1757
1758        let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1759        assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1760        assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1761        assert_eq!(settings.theme, "nord");
1762        assert_eq!(settings.tool_timeout_seconds, 90);
1763        // Unchanged fields retain defaults
1764        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1765        assert!(settings.extensions_enabled);
1766    }
1767
1768    #[test]
1769    fn test_parse_from_str_toml() {
1770        let toml_content = r#"
1771last_used_model = "claude-opus"
1772last_used_provider = "anthropic"
1773theme = "monokai"
1774tool_timeout_seconds = 45
1775"#;
1776
1777        let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1778        assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1779        assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1780        assert_eq!(settings.theme, "monokai");
1781        assert_eq!(settings.tool_timeout_seconds, 45);
1782        assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1783    }
1784
1785    #[test]
1786    fn test_layer_file_json() {
1787        let base = Settings::default();
1788
1789        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1790        let json_content = r#"{
1791            "last_used_model": "gpt-4o",
1792            "last_used_provider": "openai",
1793            "theme": "dracula",
1794            "auto_compaction": false
1795        }"#;
1796        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1797
1798        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1799        assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1800        assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1801        assert_eq!(merged.theme, "dracula");
1802        assert!(!merged.auto_compaction);
1803        // Unchanged fields retain defaults
1804        assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1805        assert!(merged.extensions_enabled);
1806        assert_eq!(merged.tool_timeout_seconds, 120);
1807    }
1808
1809    #[test]
1810    fn test_layer_file_json_preserves_unset() {
1811        let mut base = Settings::default();
1812        base.last_used_provider = Some("deepseek".to_string());
1813
1814        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1815        let json_content = r#"{ "theme": "nord" }"#;
1816        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1817
1818        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1819        assert_eq!(merged.theme, "nord");
1820        assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1821    }
1822
1823    #[test]
1824    fn test_save_to_json() {
1825        let tmp = tempfile::tempdir().unwrap();
1826        let settings_path = tmp.path().join("settings.json");
1827
1828        let mut settings = Settings::default();
1829        settings.last_used_model = Some("gpt-4o".to_string());
1830        settings.last_used_provider = Some("openai".to_string());
1831        settings.theme = "dracula".to_string();
1832        settings.tool_timeout_seconds = 60;
1833
1834        settings.save_to(&settings_path).unwrap();
1835
1836        // Verify it's valid JSON
1837        let content = fs::read_to_string(&settings_path).unwrap();
1838        let parsed: Settings = serde_json::from_str(&content).unwrap();
1839        assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1840        assert_eq!(parsed.theme, "dracula");
1841        assert_eq!(parsed.tool_timeout_seconds, 60);
1842    }
1843
1844    #[test]
1845    fn test_save_to_toml() {
1846        let tmp = tempfile::tempdir().unwrap();
1847        let settings_path = tmp.path().join("settings.toml");
1848
1849        let mut settings = Settings::default();
1850        settings.last_used_model = Some("gemini-pro".to_string());
1851        settings.last_used_provider = Some("google".to_string());
1852        settings.theme = "monokai".to_string();
1853        settings.tool_timeout_seconds = 90;
1854
1855        settings.save_to(&settings_path).unwrap();
1856
1857        // Verify it's valid TOML
1858        let content = fs::read_to_string(&settings_path).unwrap();
1859        let parsed: Settings = toml::from_str(&content).unwrap();
1860        assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1861        assert_eq!(parsed.theme, "monokai");
1862        assert_eq!(parsed.tool_timeout_seconds, 90);
1863    }
1864
1865    #[test]
1866    fn test_load_from_dir_with_json_project_config() {
1867        let _guard = EnvGuard::new(&[
1868            "OXICODE_MODEL",
1869            "OXICODE_PROVIDER",
1870            "OXICODE_THEME",
1871            "OXICODE_TOOL_TIMEOUT",
1872            "OXICODE_TEMPERATURE",
1873            "OXICODE_MAX_TOKENS",
1874            "OXICODE_SESSION_DIR",
1875            "OXICODE_EXTENSIONS_ENABLED",
1876        ]);
1877        let tmp = tempfile::tempdir().unwrap();
1878        let oxicode_dir = tmp.path().join(".oxicode");
1879        fs::create_dir_all(&oxicode_dir).unwrap();
1880        let settings_path = oxicode_dir.join("settings.json");
1881        // v3 format: default_model has provider/model
1882        let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1883        fs::write(&settings_path, json_content).unwrap();
1884
1885        let settings = Settings::load_from(tmp.path()).unwrap();
1886        // Migration splits provider from model
1887        assert_eq!(
1888            settings.last_used_model,
1889            Some("gemini-2.0-flash".to_string())
1890        );
1891        assert_eq!(settings.last_used_provider, Some("google".to_string()));
1892    }
1893
1894    #[test]
1895    fn test_find_project_settings_json_priority() {
1896        let tmp = tempfile::tempdir().unwrap();
1897        let oxicode_dir = tmp.path().join(".oxicode");
1898        fs::create_dir_all(&oxicode_dir).unwrap();
1899
1900        // Create both files
1901        let json_path = oxicode_dir.join("settings.json");
1902        let toml_path = oxicode_dir.join("settings.toml");
1903        fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1904        fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1905
1906        // JSON takes priority
1907        let found = Settings::find_project_settings(tmp.path());
1908        assert!(found.is_some());
1909        assert_eq!(
1910            found.unwrap().file_name().unwrap().to_str().unwrap(),
1911            "settings.json"
1912        );
1913    }
1914
1915    #[test]
1916    fn test_find_project_settings_json_only() {
1917        let tmp = tempfile::tempdir().unwrap();
1918        let oxicode_dir = tmp.path().join(".oxicode");
1919        fs::create_dir_all(&oxicode_dir).unwrap();
1920
1921        let json_path = oxicode_dir.join("settings.json");
1922        fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1923
1924        let found = Settings::find_project_settings(tmp.path());
1925        assert!(found.is_some());
1926        assert_eq!(
1927            found.unwrap().file_name().unwrap().to_str().unwrap(),
1928            "settings.json"
1929        );
1930    }
1931
1932    #[test]
1933    fn test_find_project_settings_toml_fallback() {
1934        let tmp = tempfile::tempdir().unwrap();
1935        let oxicode_dir = tmp.path().join(".oxicode");
1936        fs::create_dir_all(&oxicode_dir).unwrap();
1937
1938        let toml_path = oxicode_dir.join("settings.toml");
1939        fs::write(&toml_path, r#"theme = "test""#).unwrap();
1940
1941        let found = Settings::find_project_settings(tmp.path());
1942        assert!(found.is_some());
1943        assert_eq!(
1944            found.unwrap().file_name().unwrap().to_str().unwrap(),
1945            "settings.toml"
1946        );
1947    }
1948
1949    #[test]
1950    fn test_detect_format() {
1951        let json_path = PathBuf::from("/test/settings.json");
1952        let toml_path = PathBuf::from("/test/settings.toml");
1953        let unknown_path = PathBuf::from("/test/settings");
1954
1955        assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1956        assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1957        assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1958        // Default
1959    }
1960
1961    #[test]
1962    fn test_settings_format_extension() {
1963        assert_eq!(SettingsFormat::Json.extension(), "json");
1964        assert_eq!(SettingsFormat::Toml.extension(), "toml");
1965    }
1966
1967    #[test]
1968    fn test_layer_json_over_toml() {
1969        // Test that when loading, JSON takes priority over TOML
1970        let tmp = tempfile::tempdir().unwrap();
1971        let oxicode_dir = tmp.path().join(".oxicode");
1972        fs::create_dir_all(&oxicode_dir).unwrap();
1973
1974        let json_path = oxicode_dir.join("settings.json");
1975        let toml_path = oxicode_dir.join("settings.toml");
1976
1977        // JSON has model set to "json-model"
1978        fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1979        // TOML has model set to "toml-model"
1980        fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1981
1982        // JSON takes priority
1983        let settings = Settings::load_from(tmp.path()).unwrap();
1984        assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1985    }
1986
1987    #[test]
1988    fn test_mixed_format_loading() {
1989        // Test loading a TOML file through the generic layer_file
1990        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1991        let toml_content = r#"
1992last_used_model = "loaded-via-toml"
1993theme = "loaded-theme"
1994"#;
1995        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1996
1997        let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1998        assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
1999        assert_eq!(merged.theme, "loaded-theme");
2000    }
2001
2002    #[test]
2003    fn test_merge_json_values() {
2004        let base = serde_json::json!({
2005            "version": 1,
2006            "theme": "default",
2007            "extensions": ["ext1"],
2008            "nested": {
2009                "a": 1,
2010                "b": 2
2011            }
2012        });
2013
2014        let override_ = serde_json::json!({
2015            "version": 2,
2016            "theme": "dark",
2017            "extensions": ["ext2"],
2018            "nested": {
2019                "b": 20,
2020                "c": 30
2021            }
2022        });
2023
2024        let merged = merge_json_values(base, override_);
2025
2026        assert_eq!(merged["version"], 2);
2027        assert_eq!(merged["theme"], "dark");
2028        // Arrays are replaced, not merged
2029        assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2030        // Nested objects are deeply merged
2031        assert_eq!(merged["nested"]["a"], 1);
2032        assert_eq!(merged["nested"]["b"], 20);
2033        assert_eq!(merged["nested"]["c"], 30);
2034    }
2035
2036    #[test]
2037    fn test_save_project_preserves_existing_format() {
2038        let tmp = tempfile::tempdir().unwrap();
2039        let oxicode_dir = tmp.path().join(".oxicode");
2040        fs::create_dir_all(&oxicode_dir).unwrap();
2041
2042        // Create existing TOML file
2043        let toml_path = oxicode_dir.join("settings.toml");
2044        fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2045
2046        let mut settings = Settings::default();
2047        settings.theme = "new-theme".to_string();
2048        settings.save_project(tmp.path()).unwrap();
2049
2050        // Should still be TOML
2051        let content = fs::read_to_string(&toml_path).unwrap();
2052        assert!(content.contains("new-theme"));
2053        assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2054    }
2055
2056    #[test]
2057    fn test_save_project_creates_json_by_default() {
2058        let tmp = tempfile::tempdir().unwrap();
2059        let oxicode_dir = tmp.path().join(".oxicode");
2060        fs::create_dir_all(&oxicode_dir).unwrap();
2061        // Don't create any settings file
2062
2063        let mut settings = Settings::default();
2064        settings.theme = "json-theme".to_string();
2065        settings.save_project(tmp.path()).unwrap();
2066
2067        // Should create JSON file
2068        let json_path = oxicode_dir.join("settings.json");
2069        assert!(json_path.exists());
2070        let content = fs::read_to_string(&json_path).unwrap();
2071        assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2072        assert!(content.contains("json-theme"));
2073    }
2074
2075    // ── Custom provider tests ───────────────────────────────────────
2076
2077    #[test]
2078    fn test_custom_provider_default_api() {
2079        use super::CustomProvider;
2080        let cp = CustomProvider {
2081            name: "test".to_string(),
2082            base_url: "https://api.test.com/v1".to_string(),
2083            api_key_env: "TEST_API_KEY".to_string(),
2084            api: super::default_custom_provider_api(),
2085        };
2086        assert_eq!(cp.api, "openai-completions");
2087    }
2088
2089    #[test]
2090    fn test_custom_provider_toml_deserialize() {
2091        let toml_content = r#"
2092[[custom_providers]]
2093name = "minimax"
2094base_url = "https://api.minimax.chat/v1"
2095api_key_env = "MINIMAX_API_KEY"
2096api = "openai-completions"
2097
2098[[custom_providers]]
2099name = "zai"
2100base_url = "https://api.z.ai/v1"
2101api_key_env = "ZAI_API_KEY"
2102api = "openai-responses"
2103"#;
2104        let settings: Settings = toml::from_str(toml_content).unwrap();
2105        assert_eq!(settings.custom_providers.len(), 2);
2106        assert_eq!(settings.custom_providers[0].name, "minimax");
2107        assert_eq!(
2108            settings.custom_providers[0].base_url,
2109            "https://api.minimax.chat/v1"
2110        );
2111        assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2112        assert_eq!(settings.custom_providers[0].api, "openai-completions");
2113        assert_eq!(settings.custom_providers[1].name, "zai");
2114        assert_eq!(settings.custom_providers[1].api, "openai-responses");
2115    }
2116
2117    #[test]
2118    fn test_custom_provider_json_deserialize() {
2119        let json_content = r#"{
2120            "custom_providers": [
2121                {
2122                    "name": "minimax",
2123                    "base_url": "https://api.minimax.chat/v1",
2124                    "api_key_env": "MINIMAX_API_KEY",
2125                    "api": "openai-completions"
2126                }
2127            ]
2128        }"#;
2129        let settings: Settings = serde_json::from_str(json_content).unwrap();
2130        assert_eq!(settings.custom_providers.len(), 1);
2131        assert_eq!(settings.custom_providers[0].name, "minimax");
2132    }
2133
2134    #[test]
2135    fn test_custom_provider_toml_roundtrip() {
2136        let mut settings = Settings::default();
2137        settings.custom_providers.push(super::CustomProvider {
2138            name: "test".to_string(),
2139            base_url: "https://api.test.com/v1".to_string(),
2140            api_key_env: "TEST_API_KEY".to_string(),
2141            api: "openai-completions".to_string(),
2142        });
2143
2144        let toml_str = toml::to_string_pretty(&settings).unwrap();
2145        let parsed: Settings = toml::from_str(&toml_str).unwrap();
2146        assert_eq!(parsed.custom_providers.len(), 1);
2147        assert_eq!(parsed.custom_providers[0].name, "test");
2148        assert_eq!(
2149            parsed.custom_providers[0].base_url,
2150            "https://api.test.com/v1"
2151        );
2152    }
2153
2154    #[test]
2155    fn test_custom_provider_defaults_empty() {
2156        let settings = Settings::default();
2157        assert!(settings.custom_providers.is_empty());
2158    }
2159
2160    #[test]
2161    fn test_custom_provider_layer_file() {
2162        let base = Settings::default();
2163
2164        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2165        let toml_content = r#"
2166[[custom_providers]]
2167name = "my-provider"
2168base_url = "https://api.my-provider.com/v1"
2169api_key_env = "MY_PROVIDER_API_KEY"
2170"#;
2171        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2172
2173        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2174        assert_eq!(merged.custom_providers.len(), 1);
2175        assert_eq!(merged.custom_providers[0].name, "my-provider");
2176        // Default api value
2177        assert_eq!(merged.custom_providers[0].api, "openai-completions");
2178    }
2179
2180    #[test]
2181    fn settings_deserialise_hooks_array() {
2182        let toml = r#"
2183            [[hooks]]
2184            event = "PreToolUse"
2185            matcher = "bash|write"
2186            command = "echo pre"
2187            timeout_secs = 10
2188        "#;
2189        let s: Settings = toml::from_str(toml).unwrap();
2190        assert_eq!(s.hooks.len(), 1);
2191        assert_eq!(s.hooks[0].event, oxicode_sdk::ports::HookEvent::PreToolUse);
2192        assert_eq!(s.hooks[0].matcher.as_deref(), Some("bash|write"));
2193        assert_eq!(s.hooks[0].command, "echo pre");
2194        assert_eq!(s.hooks[0].timeout_secs, Some(10));
2195    }
2196
2197    #[test]
2198    fn settings_default_has_no_hooks() {
2199        let s = Settings::default();
2200        assert!(s.hooks.is_empty());
2201    }
2202}