Skip to main content

oxi/
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
12use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::env;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18/// Current settings format version.
19const SETTINGS_VERSION: u32 = 2;
20
21/// Environment variable prefix for oxi settings.
22#[allow(dead_code)]
23const ENV_PREFIX: &str = "OXI_";
24
25/// Thinking level for agent responses
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum ThinkingLevel {
29    /// No thinking (fastest)
30    None,
31    /// Minimal thinking
32    Minimal,
33    /// Standard thinking (default)
34    #[default]
35    Standard,
36    /// Thorough thinking (slowest, best quality)
37    Thorough,
38}
39
40/// Application settings
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Settings {
43    // ── Version (for migration) ──────────────────────────────────────
44    /// Settings format version. Used for automatic migration.
45    #[serde(default)]
46    pub version: u32,
47
48    // ── Core LLM settings ───────────────────────────────────────────
49    /// Thinking level for agent responses
50    #[serde(default)]
51    pub thinking_level: ThinkingLevel,
52
53    /// Color theme (e.g., "default", "monokai", "dracula")
54    #[serde(default = "default_theme")]
55    pub theme: String,
56
57    /// Default model to use (e.g., "anthropic/claude-sonnet-4-20250514")
58    pub default_model: Option<String>,
59
60    /// Default provider to use (e.g., "anthropic", "openai")
61    pub default_provider: Option<String>,
62
63    /// Max tokens for responses
64    pub max_tokens: Option<u32>,
65
66    /// Temperature for generation (0.0–2.0)
67    pub temperature: Option<f32>,
68
69    /// Default temperature as f64 (higher precision, takes precedence over `temperature`)
70    pub default_temperature: Option<f64>,
71
72    /// Maximum tokens for generation (usize variant, takes precedence over `max_tokens`)
73    pub max_response_tokens: Option<usize>,
74
75    // ── Session settings ─────────────────────────────────────────────
76    /// Session history size (entries to keep in memory)
77    #[serde(default = "default_session_history_size")]
78    pub session_history_size: usize,
79
80    /// Directory for storing sessions (default: `~/.oxi/sessions`)
81    pub session_dir: Option<PathBuf>,
82
83    // ── Behaviour flags ──────────────────────────────────────────────
84    /// Whether to stream responses
85    #[serde(default = "default_true")]
86    pub stream_responses: bool,
87
88    /// Whether extensions are enabled
89    #[serde(default = "default_true")]
90    pub extensions_enabled: bool,
91
92    /// Whether to auto-compact conversations that exceed context window
93    #[serde(default = "default_true")]
94    pub auto_compaction: bool,
95
96    // ── Timeouts ─────────────────────────────────────────────────────
97    /// Timeout in seconds for tool execution
98    #[serde(default = "default_tool_timeout")]
99    pub tool_timeout_seconds: u64,
100
101    // ── Resource lists (managed by `oxi config`) ────────────────────
102    /// List of extension paths or npm package sources to load
103    #[serde(default)]
104    pub extensions: Vec<String>,
105
106    /// List of skill paths or npm package sources to load
107    #[serde(default)]
108    pub skills: Vec<String>,
109
110    /// List of prompt template paths to load
111    #[serde(default)]
112    pub prompts: Vec<String>,
113
114    /// List of theme paths to load
115    #[serde(default)]
116    pub themes: Vec<String>,
117}
118
119fn default_theme() -> String {
120    "default".to_string()
121}
122
123fn default_session_history_size() -> usize {
124    100
125}
126
127fn default_true() -> bool {
128    true
129}
130
131fn default_tool_timeout() -> u64 {
132    120
133}
134
135impl Default for Settings {
136    fn default() -> Self {
137        Self {
138            version: SETTINGS_VERSION,
139            thinking_level: ThinkingLevel::Standard,
140            theme: default_theme(),
141            default_model: None,
142            default_provider: None,
143            max_tokens: None,
144            temperature: None,
145            default_temperature: None,
146            max_response_tokens: None,
147            session_history_size: default_session_history_size(),
148            session_dir: None,
149            stream_responses: true,
150            extensions_enabled: true,
151            auto_compaction: true,
152            tool_timeout_seconds: default_tool_timeout(),
153            extensions: Vec::new(),
154            skills: Vec::new(),
155            prompts: Vec::new(),
156            themes: Vec::new(),
157        }
158    }
159}
160
161impl Settings {
162    // ── Paths ────────────────────────────────────────────────────────
163
164    /// Get the global settings directory path (`~/.oxi`).
165    pub fn settings_dir() -> Result<PathBuf> {
166        let base = dirs::home_dir().context("Cannot determine home directory")?;
167        Ok(base.join(".oxi"))
168    }
169
170    /// Get the global settings TOML file path (`~/.oxi/settings.toml`).
171    pub fn settings_toml_path() -> Result<PathBuf> {
172        Ok(Self::settings_dir()?.join("settings.toml"))
173    }
174
175    /// Get the global settings JSON file path (`~/.oxi/settings.json`).
176    pub fn settings_json_path() -> Result<PathBuf> {
177        Ok(Self::settings_dir()?.join("settings.json"))
178    }
179
180    /// Get the global settings file path (JSON takes priority).
181    ///
182    /// Returns the path to the settings file that should be used.
183    /// If both JSON and TOML exist, JSON is returned (takes priority).
184    /// If only one exists, that path is returned.
185    /// If neither exists, returns the JSON path by default.
186    pub fn settings_path() -> Result<PathBuf> {
187        let json_path = Self::settings_json_path()?;
188        let toml_path = Self::settings_toml_path()?;
189
190        if json_path.exists() && toml_path.exists() {
191            // Both exist: JSON takes priority
192            tracing::debug!(
193                "Both settings.json and settings.toml exist, using settings.json"
194            );
195            return Ok(json_path);
196        }
197
198        if json_path.exists() {
199            return Ok(json_path);
200        }
201
202        if toml_path.exists() {
203            return Ok(toml_path);
204        }
205
206        // Neither exists: default to JSON
207        Ok(json_path)
208    }
209
210    /// Get the effective settings file path, preferring the specified format.
211    ///
212    /// If `prefer_json` is true, checks JSON first; otherwise checks TOML first.
213    /// Returns the first existing file, or the preferred path if neither exists.
214    pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
215        let json_path = Self::settings_json_path()?;
216        let toml_path = Self::settings_toml_path()?;
217
218        let (primary, secondary) = if prefer_json {
219            (&json_path, &toml_path)
220        } else {
221            (&toml_path, &json_path)
222        };
223
224        if primary.exists() {
225            return Ok(primary.clone());
226        }
227
228        if secondary.exists() {
229            return Ok(secondary.clone());
230        }
231
232        // Neither exists: return preferred path
233        Ok(primary.clone())
234    }
235
236    /// Detect the settings file format from its path.
237    pub fn detect_format(path: &Path) -> SettingsFormat {
238        match path.extension().and_then(|e| e.to_str()) {
239            Some("json") => SettingsFormat::Json,
240            Some("toml") => SettingsFormat::Toml,
241            _ => SettingsFormat::Json, // Default to JSON for unknown extensions
242        }
243    }
244
245    /// Get the project-local settings file path.
246    ///
247    /// Searches for `.oxi/settings.json` first, then `.oxi/settings.toml`.
248    /// Returns the first one found, or None if neither exists.
249    pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
250        let mut dir = start_dir.to_path_buf();
251        loop {
252            // Check JSON first (priority), then TOML
253            let json_candidate = dir.join(".oxi").join("settings.json");
254            if json_candidate.exists() {
255                return Some(json_candidate);
256            }
257
258            let toml_candidate = dir.join(".oxi").join("settings.toml");
259            if toml_candidate.exists() {
260                return Some(toml_candidate);
261            }
262
263            if !dir.pop() {
264                return None;
265            }
266        }
267    }
268
269    /// Resolve the effective session directory.
270    ///
271    /// Priority: `session_dir` field → `$OXI_SESSION_DIR` → `~/.oxi/sessions`.
272    pub fn effective_session_dir(&self) -> Result<PathBuf> {
273        if let Some(ref dir) = self.session_dir {
274            return Ok(dir.clone());
275        }
276        if let Ok(dir) = env::var("OXI_SESSION_DIR") {
277            return Ok(PathBuf::from(dir));
278        }
279        Ok(Self::settings_dir()?.join("sessions"))
280    }
281
282    // ── Loading ──────────────────────────────────────────────────────
283
284    /// Load settings, applying all layers:
285    ///
286    /// 1. Built-in defaults
287    /// 2. Global `~/.oxi/settings.toml`
288    /// 3. Project `.oxi/settings.toml`
289    /// 4. Environment variable overrides
290    pub fn load() -> Result<Self> {
291        Self::load_from_cwd()
292    }
293
294    /// Load settings with an explicit working directory for project config discovery.
295    pub fn load_from(dir: &std::path::Path) -> Result<Self> {
296        // 1. Start from defaults
297        let mut settings = Settings::default();
298
299        // 2. Layer global config
300        if let Ok(global_path) = Self::settings_path() {
301            if global_path.exists() {
302                settings = Self::layer_file(&settings, &global_path)?;
303            }
304        }
305
306        // 3. Layer project config
307        if let Some(project_path) = Self::find_project_settings(dir) {
308            settings = Self::layer_file(&settings, &project_path)?;
309        }
310
311        // 4. Layer environment variables
312        settings.apply_env();
313
314        // 5. Run migration if needed
315        settings = Self::migrate(settings)?;
316
317        Ok(settings)
318    }
319
320    /// Convenience: load from current working directory.
321    pub fn load_from_cwd() -> Result<Self> {
322        let cwd = env::current_dir().context("Cannot determine current directory")?;
323        Self::load_from(&cwd)
324    }
325
326    /// Parse a settings file (TOML or JSON) and overlay its values onto `base`.
327    ///
328    /// The format is auto-detected based on the file extension.
329    /// Fields present in the file replace those in `base`; absent fields
330    /// are left untouched.
331    fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
332        let content = fs::read_to_string(path)
333            .with_context(|| format!("Failed to read settings from {}", path.display()))?;
334
335        let format = Self::detect_format(path);
336        let overlay: serde_json::Value = match format {
337            SettingsFormat::Toml => {
338                let toml_value: toml::Value = toml::from_str(&content)
339                    .with_context(|| format!("Failed to parse TOML settings from {}", path.display()))?;
340                // Convert TOML to JSON Value for uniform merging
341                toml_value_to_json(toml_value)
342            }
343            SettingsFormat::Json => {
344                serde_json::from_str(&content)
345                    .with_context(|| format!("Failed to parse JSON settings from {}", path.display()))?
346            }
347        };
348
349        // Re-serialize the base to JSON, merge with the overlay, then
350        // deserialize back. This gives correct "only override what's
351        // present" semantics.
352        let base_json = serde_json::to_value(base)
353            .context("Failed to serialize base settings for merge")?;
354
355        let merged = merge_json_values(base_json, overlay);
356        let result: Settings = serde_json::from_value(merged)
357            .context("Failed to deserialize merged settings")?;
358
359        Ok(result)
360    }
361
362    // ── Environment variables ────────────────────────────────────────
363
364    /// Apply environment variable overrides in-place.
365    ///
366    /// Supported variables:
367    ///
368    /// | Env var                    | Setting                |
369    /// |---------------------------|------------------------|
370    /// | `OXI_MODEL`               | `default_model`        |
371    /// | `OXI_PROVIDER`            | `default_provider`     |
372    /// | `OXI_THINKING`            | `thinking_level`       |
373    /// | `OXI_THEME`               | `theme`                |
374    /// | `OXI_MAX_TOKENS`          | `max_tokens`           |
375    /// | `OXI_TEMPERATURE`         | `default_temperature`  |
376    /// | `OXI_SESSION_DIR`         | `session_dir`          |
377    /// | `OXI_STREAM`              | `stream_responses`     |
378    /// | `OXI_EXTENSIONS_ENABLED`  | `extensions_enabled`   |
379    /// | `OXI_AUTO_COMPACTION`     | `auto_compaction`      |
380    /// | `OXI_TOOL_TIMEOUT`        | `tool_timeout_seconds` |
381    pub fn apply_env(&mut self) {
382        if let Ok(v) = env::var("OXI_MODEL") {
383            self.default_model = Some(v);
384        }
385        if let Ok(v) = env::var("OXI_PROVIDER") {
386            self.default_provider = Some(v);
387        }
388        if let Ok(v) = env::var("OXI_THINKING") {
389            if let Some(level) = parse_thinking_level(&v) {
390                self.thinking_level = level;
391            }
392        }
393        if let Ok(v) = env::var("OXI_THEME") {
394            self.theme = v;
395        }
396        if let Ok(v) = env::var("OXI_MAX_TOKENS") {
397            if let Ok(n) = v.parse::<u32>() {
398                self.max_tokens = Some(n);
399            }
400        }
401        if let Ok(v) = env::var("OXI_TEMPERATURE") {
402            if let Ok(n) = v.parse::<f64>() {
403                self.default_temperature = Some(n);
404            }
405        }
406        if let Ok(v) = env::var("OXI_SESSION_DIR") {
407            self.session_dir = Some(PathBuf::from(v));
408        }
409        if let Ok(v) = env::var("OXI_STREAM") {
410            if let Ok(b) = parse_boolish(&v) {
411                self.stream_responses = b;
412            }
413        }
414        if let Ok(v) = env::var("OXI_EXTENSIONS_ENABLED") {
415            if let Ok(b) = parse_boolish(&v) {
416                self.extensions_enabled = b;
417            }
418        }
419        if let Ok(v) = env::var("OXI_AUTO_COMPACTION") {
420            if let Ok(b) = parse_boolish(&v) {
421                self.auto_compaction = b;
422            }
423        }
424        if let Ok(v) = env::var("OXI_TOOL_TIMEOUT") {
425            if let Ok(n) = v.parse::<u64>() {
426                self.tool_timeout_seconds = n;
427            }
428        }
429    }
430
431    /// Build a `Settings` instance from **only** environment variables
432    /// (all other fields stay at defaults).
433    pub fn from_env() -> Self {
434        let mut settings = Self::default();
435        settings.apply_env();
436        settings
437    }
438
439    // ── Persistence ──────────────────────────────────────────────────
440
441    /// Save settings to the global config file.
442    ///
443    /// Uses the format of the existing file if present, otherwise saves as JSON.
444    /// Preserves backward compatibility with existing TOML files.
445    pub fn save(&self) -> Result<()> {
446        let dir = Self::settings_dir()?;
447        let path = Self::settings_path()?;
448
449        if !dir.exists() {
450            fs::create_dir_all(&dir)
451                .with_context(|| format!("Failed to create settings directory {}", dir.display()))?;
452        }
453
454        let format = Self::detect_format(&path);
455        let content = Self::serialize_for_format(self, format)?;
456
457        // Atomic write: write to temp file first, then rename
458        let tmp_path = path.with_extension("tmp");
459        fs::write(&tmp_path, &content)
460            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
461        fs::rename(&tmp_path, &path)
462            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
463
464        Ok(())
465    }
466
467    /// Save settings to a specific path, using the format determined by the file extension.
468    pub fn save_to(&self, path: &Path) -> Result<()> {
469        if let Some(parent) = path.parent() {
470            if !parent.exists() {
471                fs::create_dir_all(parent)
472                    .with_context(|| format!("Failed to create directory {}", parent.display()))?;
473            }
474        }
475
476        let format = Self::detect_format(path);
477        let content = Self::serialize_for_format(self, format)?;
478
479        // Atomic write
480        let tmp_path = path.with_extension("tmp");
481        fs::write(&tmp_path, &content)
482            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
483        fs::rename(&tmp_path, path)
484            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
485
486        Ok(())
487    }
488
489    /// Save settings to the project-local config file.
490    ///
491    /// Uses the format of the existing file if present, otherwise saves as JSON.
492    pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
493        let dir = project_dir.join(".oxi");
494
495        if !dir.exists() {
496            fs::create_dir_all(&dir)
497                .with_context(|| format!("Failed to create project settings directory {}", dir.display()))?;
498        }
499
500        // Check if a settings file already exists in project
501        let json_path = dir.join("settings.json");
502        let toml_path = dir.join("settings.toml");
503
504        let path = if json_path.exists() {
505            &json_path
506        } else if toml_path.exists() {
507            &toml_path
508        } else {
509            // Default to JSON for new files
510            &json_path
511        };
512
513        let format = Self::detect_format(path);
514        let content = Self::serialize_for_format(self, format)?;
515
516        // Atomic write
517        let tmp_path = path.with_extension("tmp");
518        fs::write(&tmp_path, &content)
519            .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
520        fs::rename(&tmp_path, path)
521            .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
522
523        Ok(())
524    }
525
526    /// Serialize settings to a string in the specified format.
527    pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
528        match format {
529            SettingsFormat::Toml => toml::to_string_pretty(settings)
530                .context("Failed to serialize settings to TOML"),
531            SettingsFormat::Json => serde_json::to_string_pretty(settings)
532                .context("Failed to serialize settings to JSON"),
533        }
534    }
535
536    /// Parse settings from a string in the specified format.
537    pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
538        match format {
539            SettingsFormat::Toml => toml::from_str(content)
540                .context("Failed to parse TOML settings"),
541            SettingsFormat::Json => serde_json::from_str(content)
542                .context("Failed to parse JSON settings"),
543        }
544    }
545
546    // ── CLI overrides ────────────────────────────────────────────────
547
548    /// Merge with CLI arguments (CLI takes precedence).
549    pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
550        if let Some(m) = model {
551            self.default_model = Some(m);
552        }
553        if let Some(p) = provider {
554            self.default_provider = Some(p);
555        }
556    }
557
558    /// Get the effective model ID (provider/model format).
559    pub fn effective_model(&self, cli_model: Option<&str>) -> String {
560        cli_model
561            .map(String::from)
562            .or_else(|| self.default_model.clone())
563            .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".to_string())
564    }
565
566    /// Get the effective provider.
567    pub fn effective_provider(&self, cli_provider: Option<&str>) -> String {
568        cli_provider
569            .map(String::from)
570            .or_else(|| self.default_provider.clone())
571            .unwrap_or_else(|| "anthropic".to_string())
572    }
573
574    /// Get the effective temperature, preferring `default_temperature` (f64)
575    /// over `temperature` (f32), falling back to `None`.
576    pub fn effective_temperature(&self) -> Option<f64> {
577        self.default_temperature
578            .or(self.temperature.map(|t| t as f64))
579    }
580
581    /// Get the effective max tokens, preferring `max_response_tokens` (usize)
582    /// over `max_tokens` (u32), falling back to `None`.
583    pub fn effective_max_tokens(&self) -> Option<usize> {
584        self.max_response_tokens.or(self.max_tokens.map(|t| t as usize))
585    }
586
587    // ── Migration ────────────────────────────────────────────────────
588
589    /// Migrate settings from an older format version to the current one.
590    ///
591    /// Currently handles:
592    /// - Version 0 → Version 2 (adds JSON support, version bump)
593    /// - Version 1 → Version 2 (adds JSON support)
594    fn migrate(settings: Settings) -> Result<Settings> {
595        let mut settings = settings;
596
597        match settings.version {
598            SETTINGS_VERSION => {
599                // Already current — nothing to do.
600            }
601            0 => {
602                // Version 0 = pre-versioning config.
603                // Add any defaults that were introduced in version 1.
604                if settings.tool_timeout_seconds == 0 {
605                    settings.tool_timeout_seconds = default_tool_timeout();
606                }
607                settings.version = SETTINGS_VERSION;
608
609                tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
610            }
611            v if v > SETTINGS_VERSION => {
612                // Future version — we don't know how to downgrade.
613                anyhow::bail!(
614                    "Settings version {} is newer than supported version {}. \
615                     Please update oxi.",
616                    v,
617                    SETTINGS_VERSION
618                );
619            }
620            v => {
621                // Unknown old version — best-effort migration.
622                tracing::warn!(
623                    "Unknown settings version {}, attempting migration to {}",
624                    v,
625                    SETTINGS_VERSION
626                );
627                settings.version = SETTINGS_VERSION;
628            }
629        }
630
631        Ok(settings)
632    }
633}
634
635// ── Settings format detection ──────────────────────────────────────
636
637/// Supported settings file formats.
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
639pub enum SettingsFormat {
640    #[default]
641    Json,
642    Toml,
643}
644
645impl SettingsFormat {
646    /// Get the file extension for this format.
647    pub fn extension(&self) -> &'static str {
648        match self {
649            SettingsFormat::Json => "json",
650            SettingsFormat::Toml => "toml",
651        }
652    }
653}
654
655// ── JSON/TOML conversion helpers ────────────────────────────────────
656
657/// Convert a TOML Value to a serde_json::Value.
658fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
659    match toml {
660        toml::Value::String(s) => serde_json::Value::String(s),
661        toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
662        toml::Value::Float(f) => {
663            serde_json::Number::from_f64(f).map(serde_json::Value::Number)
664                .unwrap_or(serde_json::Value::Null)
665        }
666        toml::Value::Boolean(b) => serde_json::Value::Bool(b),
667        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
668        toml::Value::Array(arr) => {
669            serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
670        }
671        toml::Value::Table(table) => {
672            let obj = table
673                .into_iter()
674                .map(|(k, v)| (k, toml_value_to_json(v)))
675                .collect();
676            serde_json::Value::Object(obj)
677        }
678    }
679}
680
681/// Deep merge two JSON values. The second value overrides the first.
682fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
683    match (base, override_) {
684        // If either is not an object, the override wins
685        (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
686            let mut result = base_map;
687            for (key, override_value) in override_map {
688                let base_value = result.remove(&key);
689                let merged = match base_value {
690                    Some(base_v) => merge_json_values(base_v, override_value),
691                    None => override_value,
692                };
693                result.insert(key, merged);
694            }
695            serde_json::Value::Object(result)
696        }
697        // Override wins for non-objects
698        (_, override_) => override_,
699    }
700}
701
702/// Parse a thinking level from a string.
703pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
704    match s.to_lowercase().as_str() {
705        "none" => Some(ThinkingLevel::None),
706        "minimal" => Some(ThinkingLevel::Minimal),
707        "standard" => Some(ThinkingLevel::Standard),
708        "thorough" => Some(ThinkingLevel::Thorough),
709        _ => None,
710    }
711}
712
713/// Parse a boolean-like string (`"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"`).
714fn parse_boolish(s: &str) -> Result<bool> {
715    match s.to_lowercase().as_str() {
716        "true" | "1" | "yes" | "on" => Ok(true),
717        "false" | "0" | "no" | "off" => Ok(false),
718        _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use std::io::Write as IoWrite;
726
727    /// RAII guard that removes listed env vars on creation and restores them on drop.
728    /// This prevents parallel test races where one test sets an env var that leaks into another.
729    struct EnvGuard {
730        saved: Vec<(String, Option<String>)>,
731    }
732
733    impl EnvGuard {
734        fn new(vars: &[&str]) -> Self {
735            let saved = vars
736                .iter()
737                .map(|&name| {
738                    let old = env::var(name).ok();
739                    env::remove_var(name);
740                    (name.to_string(), old)
741                })
742                .collect();
743            Self { saved }
744        }
745    }
746
747    impl Drop for EnvGuard {
748        fn drop(&mut self) {
749            for (name, old) in self.saved.drain(..) {
750                match old {
751                    Some(val) => env::set_var(&name, val),
752                    None => env::remove_var(&name),
753                }
754            }
755        }
756    }
757
758    // ── Struct tests ─────────────────────────────────────────────────
759
760    #[test]
761    fn test_default_settings() {
762        let settings = Settings::default();
763        assert_eq!(settings.version, SETTINGS_VERSION);
764        assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
765        assert_eq!(settings.theme, "default");
766        assert!(settings.default_model.is_none());
767        assert!(settings.default_provider.is_none());
768        assert!(settings.extensions_enabled);
769        assert!(settings.auto_compaction);
770        assert_eq!(settings.tool_timeout_seconds, 120);
771        assert!(settings.stream_responses);
772    }
773
774    #[test]
775    fn test_merge_cli() {
776        let mut settings = Settings::default();
777        settings.default_model = Some("openai/gpt-4o".to_string());
778
779        settings.merge_cli(Some("claude".to_string()), None);
780        assert_eq!(settings.default_model, Some("claude".to_string()));
781
782        settings.merge_cli(None, Some("google".to_string()));
783        assert_eq!(settings.default_provider, Some("google".to_string()));
784    }
785
786    // ── Layered loading ──────────────────────────────────────────────
787
788    #[test]
789    fn test_layer_file_overrides() {
790        let base = Settings::default();
791
792        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
793        let toml_content = r#"
794default_model = "openai/gpt-4o"
795theme = "dracula"
796"#;
797        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
798
799        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
800        assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
801        assert_eq!(merged.theme, "dracula");
802        // Unchanged fields retain defaults
803        assert_eq!(merged.thinking_level, ThinkingLevel::Standard);
804        assert!(merged.extensions_enabled);
805    }
806
807    #[test]
808    fn test_layer_file_preserves_unset() {
809        let mut base = Settings::default();
810        base.default_provider = Some("deepseek".to_string());
811
812        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
813        // Only override theme — provider should remain
814        let toml_content = "theme = \"monokai\"\n";
815        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
816
817        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
818        assert_eq!(merged.theme, "monokai");
819        assert_eq!(merged.default_provider, Some("deepseek".to_string()));
820    }
821
822    #[test]
823    fn test_load_from_dir_with_project_config() {
824        let tmp = tempfile::tempdir().unwrap();
825        let oxi_dir = tmp.path().join(".oxi");
826        fs::create_dir_all(&oxi_dir).unwrap();
827        let settings_path = oxi_dir.join("settings.toml");
828        fs::write(&settings_path, "default_model = \"google/gemini-2.0-flash\"\n").unwrap();
829
830        let settings = Settings::load_from(tmp.path()).unwrap();
831        assert_eq!(settings.default_model, Some("google/gemini-2.0-flash".to_string()));
832    }
833
834    #[test]
835    fn test_load_from_dir_no_config() {
836        // Clean env vars that load_from() reads via apply_env()
837        let _guard = EnvGuard::new(&[
838            "OXI_MODEL",
839            "OXI_PROVIDER",
840            "OXI_THEME",
841            "OXI_TOOL_TIMEOUT",
842            "OXI_TEMPERATURE",
843            "OXI_MAX_TOKENS",
844            "OXI_SESSION_DIR",
845            "OXI_STREAM",
846            "OXI_EXTENSIONS_ENABLED",
847        ]);
848        let tmp = tempfile::tempdir().unwrap();
849        let settings = Settings::load_from(tmp.path()).unwrap();
850        // Falls back to defaults
851        assert!(settings.default_model.is_none());
852        assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
853    }
854
855    // ── Environment variables ────────────────────────────────────────
856
857    #[test]
858    fn test_from_env() {
859        let _guard = EnvGuard::new(&[
860            "OXI_MODEL",
861            "OXI_THEME",
862            "OXI_TOOL_TIMEOUT",
863        ]);
864        env::set_var("OXI_MODEL", "anthropic/claude-haiku-4-20250414");
865        env::set_var("OXI_THEME", "nord");
866        env::set_var("OXI_TOOL_TIMEOUT", "60");
867
868        let settings = Settings::from_env();
869        assert_eq!(settings.default_model, Some("anthropic/claude-haiku-4-20250414".to_string()));
870        assert_eq!(settings.theme, "nord");
871        assert_eq!(settings.tool_timeout_seconds, 60);
872    }
873
874    #[test]
875    fn test_apply_env_boolish() {
876        let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
877        env::set_var("OXI_STREAM", "false");
878        env::set_var("OXI_EXTENSIONS_ENABLED", "0");
879
880        let mut settings = Settings::default();
881        settings.apply_env();
882        assert!(!settings.stream_responses);
883        assert!(!settings.extensions_enabled);
884    }
885
886    #[test]
887    fn test_apply_env_temperature() {
888        let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
889        env::set_var("OXI_TEMPERATURE", "0.7");
890
891        let mut settings = Settings::default();
892        settings.apply_env();
893        assert_eq!(settings.default_temperature, Some(0.7));
894    }
895
896    #[test]
897    fn test_env_does_not_override_when_unset() {
898        let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER"]);
899        let settings = Settings::from_env();
900        assert!(settings.default_model.is_none());
901        assert!(settings.default_provider.is_none());
902    }
903
904    // ── Helpers ──────────────────────────────────────────────────────
905
906    #[test]
907    fn test_parse_thinking_level() {
908        assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::None));
909        assert_eq!(parse_thinking_level("MINIMAL"), Some(ThinkingLevel::Minimal));
910        assert_eq!(parse_thinking_level("Standard"), Some(ThinkingLevel::Standard));
911        assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::Thorough));
912        assert_eq!(parse_thinking_level("invalid"), None);
913    }
914
915    #[test]
916    fn test_parse_boolish() {
917        assert!(parse_boolish("true").unwrap());
918        assert!(parse_boolish("1").unwrap());
919        assert!(parse_boolish("yes").unwrap());
920        assert!(parse_boolish("ON").unwrap());
921        assert!(!parse_boolish("false").unwrap());
922        assert!(!parse_boolish("0").unwrap());
923        assert!(!parse_boolish("no").unwrap());
924        assert!(!parse_boolish("OFF").unwrap());
925        assert!(parse_boolish("maybe").is_err());
926    }
927
928    // ── Effective accessors ──────────────────────────────────────────
929
930    #[test]
931    fn test_effective_temperature_prefers_f64() {
932        let mut settings = Settings::default();
933        settings.temperature = Some(0.5);
934        settings.default_temperature = Some(0.7);
935        assert_eq!(settings.effective_temperature(), Some(0.7));
936    }
937
938    #[test]
939    fn test_effective_temperature_falls_back_to_f32() {
940        let mut settings = Settings::default();
941        settings.temperature = Some(0.5);
942        assert_eq!(settings.effective_temperature(), Some(0.5));
943    }
944
945    #[test]
946    fn test_effective_max_tokens_prefers_usize() {
947        let mut settings = Settings::default();
948        settings.max_tokens = Some(1024);
949        settings.max_response_tokens = Some(4096);
950        assert_eq!(settings.effective_max_tokens(), Some(4096));
951    }
952
953    #[test]
954    fn test_effective_max_tokens_falls_back_to_u32() {
955        let mut settings = Settings::default();
956        settings.max_tokens = Some(1024);
957        assert_eq!(settings.effective_max_tokens(), Some(1024));
958    }
959
960    // ── Session dir ──────────────────────────────────────────────────
961
962    #[test]
963    fn test_effective_session_dir_default() {
964        env::remove_var("OXI_SESSION_DIR");
965        let settings = Settings::default();
966        let dir = settings.effective_session_dir().unwrap();
967        assert!(dir.ends_with("sessions"));
968    }
969
970    #[test]
971    fn test_effective_session_dir_from_field() {
972        env::remove_var("OXI_SESSION_DIR");
973        let mut settings = Settings::default();
974        settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
975        assert_eq!(settings.effective_session_dir().unwrap(), PathBuf::from("/tmp/oxi-sessions"));
976    }
977
978    #[test]
979    fn test_effective_session_dir_from_env() {
980        env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
981        let settings = Settings::default();
982        assert_eq!(settings.effective_session_dir().unwrap(), PathBuf::from("/tmp/env-sessions"));
983        env::remove_var("OXI_SESSION_DIR");
984    }
985
986    // ── Migration ────────────────────────────────────────────────────
987
988    #[test]
989    fn test_migration_v0_to_v1() {
990        let mut settings = Settings::default();
991        settings.version = 0;
992        settings.tool_timeout_seconds = 0; // v0 might not have this field
993
994        let migrated = Settings::migrate(settings).unwrap();
995        assert_eq!(migrated.version, SETTINGS_VERSION);
996        assert_eq!(migrated.tool_timeout_seconds, 120);
997    }
998
999    #[test]
1000    fn test_migration_already_current() {
1001        let settings = Settings::default();
1002        let migrated = Settings::migrate(settings).unwrap();
1003        assert_eq!(migrated.version, SETTINGS_VERSION);
1004    }
1005
1006    #[test]
1007    fn test_migration_future_version_fails() {
1008        let mut settings = Settings::default();
1009        settings.version = 9999;
1010        assert!(Settings::migrate(settings).is_err());
1011    }
1012
1013    // ── Persistence ──────────────────────────────────────────────────
1014
1015    #[test]
1016    fn test_save_and_load_roundtrip() {
1017        let tmp = tempfile::tempdir().unwrap();
1018        let settings_path = tmp.path().join("settings.toml");
1019
1020        let mut original = Settings::default();
1021        original.default_model = Some("openai/gpt-4o".to_string());
1022        original.theme = "dracula".to_string();
1023        original.tool_timeout_seconds = 60;
1024
1025        // Serialize
1026        let content = toml::to_string_pretty(&original).unwrap();
1027        fs::write(&settings_path, &content).unwrap();
1028
1029        // Deserialize
1030        let loaded_content = fs::read_to_string(&settings_path).unwrap();
1031        let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1032
1033        assert_eq!(loaded.default_model, original.default_model);
1034        assert_eq!(loaded.theme, original.theme);
1035        assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1036    }
1037
1038    #[test]
1039    fn test_toml_roundtrip_preserves_new_fields() {
1040        let mut settings = Settings::default();
1041        settings.default_temperature = Some(0.8);
1042        settings.max_response_tokens = Some(8192);
1043        settings.auto_compaction = false;
1044        settings.extensions_enabled = false;
1045        settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1046
1047        let toml_str = toml::to_string_pretty(&settings).unwrap();
1048        let parsed: Settings = toml::from_str(&toml_str).unwrap();
1049
1050        assert_eq!(parsed.default_temperature, Some(0.8));
1051        assert_eq!(parsed.max_response_tokens, Some(8192));
1052        assert!(!parsed.auto_compaction);
1053        assert!(!parsed.extensions_enabled);
1054        assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1055    }
1056
1057    // ── JSON format tests ──────────────────────────────────────────────
1058
1059    #[test]
1060    fn test_json_roundtrip() {
1061        let mut settings = Settings::default();
1062        settings.default_model = Some("openai/gpt-4o".to_string());
1063        settings.theme = "dracula".to_string();
1064        settings.tool_timeout_seconds = 60;
1065        settings.default_temperature = Some(0.8);
1066        settings.max_response_tokens = Some(8192);
1067
1068        let json_str = serde_json::to_string_pretty(&settings).unwrap();
1069        let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1070
1071        assert_eq!(parsed.default_model, settings.default_model);
1072        assert_eq!(parsed.theme, settings.theme);
1073        assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1074        assert_eq!(parsed.default_temperature, settings.default_temperature);
1075        assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1076    }
1077
1078    #[test]
1079    fn test_json_serialize_for_format() {
1080        let mut settings = Settings::default();
1081        settings.default_model = Some("anthropic/claude-3".to_string());
1082        settings.thinking_level = ThinkingLevel::Minimal;
1083
1084        let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1085        let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1086
1087        assert_eq!(parsed.default_model, Some("anthropic/claude-3".to_string()));
1088        assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1089    }
1090
1091    #[test]
1092    fn test_toml_serialize_for_format() {
1093        let mut settings = Settings::default();
1094        settings.default_model = Some("google/gemini-pro".to_string());
1095        settings.thinking_level = ThinkingLevel::Thorough;
1096
1097        let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1098        let parsed: Settings = toml::from_str(&toml_content).unwrap();
1099
1100        assert_eq!(parsed.default_model, Some("google/gemini-pro".to_string()));
1101        assert_eq!(parsed.thinking_level, ThinkingLevel::Thorough);
1102    }
1103
1104    #[test]
1105    fn test_parse_from_str_json() {
1106        let json_content = r#"{
1107            "default_model": "openai/gpt-4",
1108            "theme": "nord",
1109            "tool_timeout_seconds": 90
1110        }"#;
1111
1112        let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1113        assert_eq!(settings.default_model, Some("openai/gpt-4".to_string()));
1114        assert_eq!(settings.theme, "nord");
1115        assert_eq!(settings.tool_timeout_seconds, 90);
1116        // Unchanged fields retain defaults
1117        assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
1118        assert!(settings.extensions_enabled);
1119    }
1120
1121    #[test]
1122    fn test_parse_from_str_toml() {
1123        let toml_content = r#"
1124default_model = "anthropic/claude-opus"
1125theme = "monokai"
1126tool_timeout_seconds = 45
1127"#;
1128
1129        let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1130        assert_eq!(settings.default_model, Some("anthropic/claude-opus".to_string()));
1131        assert_eq!(settings.theme, "monokai");
1132        assert_eq!(settings.tool_timeout_seconds, 45);
1133        assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
1134    }
1135
1136    #[test]
1137    fn test_layer_file_json() {
1138        let base = Settings::default();
1139
1140        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1141        let json_content = r#"{
1142            "default_model": "openai/gpt-4o",
1143            "theme": "dracula",
1144            "auto_compaction": false
1145        }"#;
1146        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1147
1148        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1149        assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
1150        assert_eq!(merged.theme, "dracula");
1151        assert!(!merged.auto_compaction);
1152        // Unchanged fields retain defaults
1153        assert_eq!(merged.thinking_level, ThinkingLevel::Standard);
1154        assert!(merged.extensions_enabled);
1155        assert_eq!(merged.tool_timeout_seconds, 120);
1156    }
1157
1158    #[test]
1159    fn test_layer_file_json_preserves_unset() {
1160        let mut base = Settings::default();
1161        base.default_provider = Some("deepseek".to_string());
1162
1163        let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1164        let json_content = r#"{ "theme": "nord" }"#;
1165        tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1166
1167        let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1168        assert_eq!(merged.theme, "nord");
1169        assert_eq!(merged.default_provider, Some("deepseek".to_string()));
1170    }
1171
1172    #[test]
1173    fn test_save_to_json() {
1174        let tmp = tempfile::tempdir().unwrap();
1175        let settings_path = tmp.path().join("settings.json");
1176
1177        let mut settings = Settings::default();
1178        settings.default_model = Some("openai/gpt-4o".to_string());
1179        settings.theme = "dracula".to_string();
1180        settings.tool_timeout_seconds = 60;
1181
1182        settings.save_to(&settings_path).unwrap();
1183
1184        // Verify it's valid JSON
1185        let content = fs::read_to_string(&settings_path).unwrap();
1186        let parsed: Settings = serde_json::from_str(&content).unwrap();
1187        assert_eq!(parsed.default_model, Some("openai/gpt-4o".to_string()));
1188        assert_eq!(parsed.theme, "dracula");
1189        assert_eq!(parsed.tool_timeout_seconds, 60);
1190    }
1191
1192    #[test]
1193    fn test_save_to_toml() {
1194        let tmp = tempfile::tempdir().unwrap();
1195        let settings_path = tmp.path().join("settings.toml");
1196
1197        let mut settings = Settings::default();
1198        settings.default_model = Some("google/gemini-pro".to_string());
1199        settings.theme = "monokai".to_string();
1200        settings.tool_timeout_seconds = 90;
1201
1202        settings.save_to(&settings_path).unwrap();
1203
1204        // Verify it's valid TOML
1205        let content = fs::read_to_string(&settings_path).unwrap();
1206        let parsed: Settings = toml::from_str(&content).unwrap();
1207        assert_eq!(parsed.default_model, Some("google/gemini-pro".to_string()));
1208        assert_eq!(parsed.theme, "monokai");
1209        assert_eq!(parsed.tool_timeout_seconds, 90);
1210    }
1211
1212    #[test]
1213    fn test_load_from_dir_with_json_project_config() {
1214        let tmp = tempfile::tempdir().unwrap();
1215        let oxi_dir = tmp.path().join(".oxi");
1216        fs::create_dir_all(&oxi_dir).unwrap();
1217        let settings_path = oxi_dir.join("settings.json");
1218        let json_content = r#"{ "default_model": "google/gemini-2.0-flash" }"#;
1219        fs::write(&settings_path, json_content).unwrap();
1220
1221        let settings = Settings::load_from(tmp.path()).unwrap();
1222        assert_eq!(settings.default_model, Some("google/gemini-2.0-flash".to_string()));
1223    }
1224
1225    #[test]
1226    fn test_find_project_settings_json_priority() {
1227        let tmp = tempfile::tempdir().unwrap();
1228        let oxi_dir = tmp.path().join(".oxi");
1229        fs::create_dir_all(&oxi_dir).unwrap();
1230
1231        // Create both files
1232        let json_path = oxi_dir.join("settings.json");
1233        let toml_path = oxi_dir.join("settings.toml");
1234        fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1235        fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1236
1237        // JSON takes priority
1238        let found = Settings::find_project_settings(tmp.path());
1239        assert!(found.is_some());
1240        assert_eq!(found.unwrap().file_name().unwrap().to_str().unwrap(), "settings.json");
1241    }
1242
1243    #[test]
1244    fn test_find_project_settings_json_only() {
1245        let tmp = tempfile::tempdir().unwrap();
1246        let oxi_dir = tmp.path().join(".oxi");
1247        fs::create_dir_all(&oxi_dir).unwrap();
1248
1249        let json_path = oxi_dir.join("settings.json");
1250        fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1251
1252        let found = Settings::find_project_settings(tmp.path());
1253        assert!(found.is_some());
1254        assert_eq!(found.unwrap().file_name().unwrap().to_str().unwrap(), "settings.json");
1255    }
1256
1257    #[test]
1258    fn test_find_project_settings_toml_fallback() {
1259        let tmp = tempfile::tempdir().unwrap();
1260        let oxi_dir = tmp.path().join(".oxi");
1261        fs::create_dir_all(&oxi_dir).unwrap();
1262
1263        let toml_path = oxi_dir.join("settings.toml");
1264        fs::write(&toml_path, r#"theme = "test""#).unwrap();
1265
1266        let found = Settings::find_project_settings(tmp.path());
1267        assert!(found.is_some());
1268        assert_eq!(found.unwrap().file_name().unwrap().to_str().unwrap(), "settings.toml");
1269    }
1270
1271    #[test]
1272    fn test_detect_format() {
1273        let json_path = PathBuf::from("/test/settings.json");
1274        let toml_path = PathBuf::from("/test/settings.toml");
1275        let unknown_path = PathBuf::from("/test/settings");
1276
1277        assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1278        assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1279        assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json); // Default
1280    }
1281
1282    #[test]
1283    fn test_settings_format_extension() {
1284        assert_eq!(SettingsFormat::Json.extension(), "json");
1285        assert_eq!(SettingsFormat::Toml.extension(), "toml");
1286    }
1287
1288    #[test]
1289    fn test_layer_json_over_toml() {
1290        // Test that when loading, JSON takes priority over TOML
1291        let tmp = tempfile::tempdir().unwrap();
1292        let oxi_dir = tmp.path().join(".oxi");
1293        fs::create_dir_all(&oxi_dir).unwrap();
1294
1295        let json_path = oxi_dir.join("settings.json");
1296        let toml_path = oxi_dir.join("settings.toml");
1297
1298        // JSON has model set to "json-model"
1299        fs::write(&json_path, r#"{ "default_model": "json-model" }"#).unwrap();
1300        // TOML has model set to "toml-model"
1301        fs::write(&toml_path, r#"default_model = "toml-model""#).unwrap();
1302
1303        // JSON takes priority
1304        let settings = Settings::load_from(tmp.path()).unwrap();
1305        assert_eq!(settings.default_model, Some("json-model".to_string()));
1306    }
1307
1308    #[test]
1309    fn test_mixed_format_loading() {
1310        // Test loading a TOML file through the generic layer_file
1311        let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1312        let toml_content = r#"
1313default_model = "loaded-via-toml"
1314theme = "loaded-theme"
1315stream_responses = false
1316"#;
1317        tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1318
1319        let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1320        assert_eq!(merged.default_model, Some("loaded-via-toml".to_string()));
1321        assert_eq!(merged.theme, "loaded-theme");
1322        assert!(!merged.stream_responses);
1323    }
1324
1325    #[test]
1326    fn test_merge_json_values() {
1327        use std::collections::HashMap;
1328
1329        let base = serde_json::json!({
1330            "version": 1,
1331            "theme": "default",
1332            "extensions": ["ext1"],
1333            "nested": {
1334                "a": 1,
1335                "b": 2
1336            }
1337        });
1338
1339        let override_ = serde_json::json!({
1340            "version": 2,
1341            "theme": "dark",
1342            "extensions": ["ext2"],
1343            "nested": {
1344                "b": 20,
1345                "c": 30
1346            }
1347        });
1348
1349        let merged = merge_json_values(base, override_);
1350
1351        assert_eq!(merged["version"], 2);
1352        assert_eq!(merged["theme"], "dark");
1353        // Arrays are replaced, not merged
1354        assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1355        // Nested objects are deeply merged
1356        assert_eq!(merged["nested"]["a"], 1);
1357        assert_eq!(merged["nested"]["b"], 20);
1358        assert_eq!(merged["nested"]["c"], 30);
1359    }
1360
1361    #[test]
1362    fn test_save_project_preserves_existing_format() {
1363        let tmp = tempfile::tempdir().unwrap();
1364        let oxi_dir = tmp.path().join(".oxi");
1365        fs::create_dir_all(&oxi_dir).unwrap();
1366
1367        // Create existing TOML file
1368        let toml_path = oxi_dir.join("settings.toml");
1369        fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1370
1371        let mut settings = Settings::default();
1372        settings.theme = "new-theme".to_string();
1373        settings.save_project(tmp.path()).unwrap();
1374
1375        // Should still be TOML
1376        let content = fs::read_to_string(&toml_path).unwrap();
1377        assert!(content.contains("new-theme"));
1378        assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1379    }
1380
1381    #[test]
1382    fn test_save_project_creates_json_by_default() {
1383        let tmp = tempfile::tempdir().unwrap();
1384        let oxi_dir = tmp.path().join(".oxi");
1385        fs::create_dir_all(&oxi_dir).unwrap();
1386        // Don't create any settings file
1387
1388        let mut settings = Settings::default();
1389        settings.theme = "json-theme".to_string();
1390        settings.save_project(tmp.path()).unwrap();
1391
1392        // Should create JSON file
1393        let json_path = oxi_dir.join("settings.json");
1394        assert!(json_path.exists());
1395        let content = fs::read_to_string(&json_path).unwrap();
1396        assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1397        assert!(content.contains("json-theme"));
1398    }
1399}