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