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