Skip to main content

oxi/store/
settings.rs

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