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