1use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::env;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19const SETTINGS_VERSION: u32 = 4;
21
22#[allow(dead_code)]
25const ENV_PREFIX: &str = "OXI_";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum ThinkingLevel {
31 #[default]
33 Off,
34 Minimal,
36 Low,
38 Medium,
40 High,
42 XHigh,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CustomProvider {
52 pub name: String,
54 pub base_url: String,
56 pub api_key_env: String,
58 #[serde(default = "default_custom_provider_api")]
60 pub api: String,
61}
62
63fn default_custom_provider_api() -> String {
64 "openai-completions".to_string()
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Settings {
70 #[serde(default)]
73 pub version: u32,
74
75 #[serde(default = "default_thinking_level")]
78 pub thinking_level: ThinkingLevel,
79
80 #[serde(default = "default_theme")]
82 pub theme: String,
83
84 #[serde(default, skip_serializing)]
86 pub default_model: Option<String>,
87
88 #[serde(default, skip_serializing)]
90 pub default_provider: Option<String>,
91
92 #[serde(default)]
95 pub last_used_model: Option<String>,
96
97 #[serde(default)]
99 pub last_used_provider: Option<String>,
100
101 pub max_tokens: Option<u32>,
103
104 pub temperature: Option<f32>,
106
107 pub default_temperature: Option<f64>,
109
110 pub max_response_tokens: Option<usize>,
112
113 #[serde(default = "default_session_history_size")]
116 pub session_history_size: usize,
117
118 pub session_dir: Option<PathBuf>,
120
121 #[serde(default = "default_true")]
124 pub stream_responses: bool,
125
126 #[serde(default = "default_true")]
128 pub extensions_enabled: bool,
129
130 #[serde(default = "default_true")]
132 pub auto_compaction: bool,
133
134 #[serde(default)]
137 pub disabled_tools: Vec<String>,
138
139 #[serde(default = "default_tool_timeout")]
142 pub tool_timeout_seconds: u64,
143
144 #[serde(default)]
147 pub extensions: Vec<String>,
148
149 #[serde(default)]
151 pub skills: Vec<String>,
152
153 #[serde(default)]
155 pub prompts: Vec<String>,
156
157 #[serde(default)]
159 pub themes: Vec<String>,
160
161 #[serde(default)]
164 pub custom_providers: Vec<CustomProvider>,
165
166 #[serde(default)]
171 pub dynamic_models: HashMap<String, Vec<String>>,
172
173 #[serde(default = "default_false")]
176 pub enable_routing: bool,
177
178 #[serde(default)]
180 pub router_profile: Option<String>,
181
182 #[serde(default = "default_true")]
184 pub prefer_cost_efficient: bool,
185
186 #[serde(default)]
188 pub fallback_chain: Vec<String>,
189
190 #[serde(default = "default_true")]
192 pub enable_fallback: bool,
193
194 #[serde(default)]
196 pub disable_fallback: bool,
197
198 #[serde(default = "default_circuit_failure_threshold")]
200 pub circuit_breaker_failure_threshold: u32,
201
202 #[serde(default = "default_circuit_open_duration_secs")]
204 pub circuit_breaker_open_duration_secs: u64,
205
206 #[serde(default)]
211 pub keybindings: HashMap<String, Vec<String>>,
212}
213
214fn default_theme() -> String {
215 "default".to_string()
216}
217
218fn default_thinking_level() -> ThinkingLevel {
219 ThinkingLevel::Medium
220}
221
222fn default_session_history_size() -> usize {
223 100
224}
225
226fn default_true() -> bool {
227 true
228}
229
230fn default_false() -> bool {
231 false
232}
233
234fn default_circuit_failure_threshold() -> u32 {
235 5
236}
237
238fn default_circuit_open_duration_secs() -> u64 {
239 30
240}
241
242fn default_tool_timeout() -> u64 {
243 120
244}
245
246impl Default for Settings {
247 fn default() -> Self {
248 Self {
249 version: SETTINGS_VERSION,
250 thinking_level: ThinkingLevel::Medium,
251 theme: default_theme(),
252 last_used_model: None,
253 last_used_provider: None,
254 default_model: None,
255 default_provider: None,
256 max_tokens: None,
257 temperature: None,
258 default_temperature: None,
259 max_response_tokens: None,
260 session_history_size: default_session_history_size(),
261 session_dir: None,
262 stream_responses: true,
263 extensions_enabled: true,
264 auto_compaction: true,
265 disabled_tools: Vec::new(),
266 tool_timeout_seconds: default_tool_timeout(),
267 extensions: Vec::new(),
268 skills: Vec::new(),
269 prompts: Vec::new(),
270 themes: Vec::new(),
271 custom_providers: Vec::new(),
272 dynamic_models: HashMap::new(),
273 enable_routing: false,
275 router_profile: None,
276 prefer_cost_efficient: true,
277 fallback_chain: Vec::new(),
278 enable_fallback: true,
279 disable_fallback: false,
280 circuit_breaker_failure_threshold: 5,
281 circuit_breaker_open_duration_secs: 30,
282 keybindings: HashMap::new(),
283 }
284 }
285}
286
287impl Settings {
288 pub fn settings_dir() -> Result<PathBuf> {
292 let base = dirs::home_dir().context("Cannot determine home directory")?;
293 Ok(base.join(".oxi"))
294 }
295
296 pub fn settings_toml_path() -> Result<PathBuf> {
298 Ok(Self::settings_dir()?.join("settings.toml"))
299 }
300
301 pub fn settings_json_path() -> Result<PathBuf> {
303 Ok(Self::settings_dir()?.join("settings.json"))
304 }
305
306 pub fn settings_path() -> Result<PathBuf> {
313 let json_path = Self::settings_json_path()?;
314 let toml_path = Self::settings_toml_path()?;
315
316 if json_path.exists() && toml_path.exists() {
317 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
319 return Ok(json_path);
320 }
321
322 if json_path.exists() {
323 return Ok(json_path);
324 }
325
326 if toml_path.exists() {
327 return Ok(toml_path);
328 }
329
330 Ok(json_path)
332 }
333
334 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
339 let json_path = Self::settings_json_path()?;
340 let toml_path = Self::settings_toml_path()?;
341
342 let (primary, secondary) = if prefer_json {
343 (&json_path, &toml_path)
344 } else {
345 (&toml_path, &json_path)
346 };
347
348 if primary.exists() {
349 return Ok(primary.clone());
350 }
351
352 if secondary.exists() {
353 return Ok(secondary.clone());
354 }
355
356 Ok(primary.clone())
358 }
359
360 pub fn detect_format(path: &Path) -> SettingsFormat {
362 match path.extension().and_then(|e| e.to_str()) {
363 Some("json") => SettingsFormat::Json,
364 Some("toml") => SettingsFormat::Toml,
365 _ => SettingsFormat::Json, }
367 }
368
369 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
374 let mut dir = start_dir.to_path_buf();
375 loop {
376 let json_candidate = dir.join(".oxi").join("settings.json");
378 if json_candidate.exists() {
379 return Some(json_candidate);
380 }
381
382 let toml_candidate = dir.join(".oxi").join("settings.toml");
383 if toml_candidate.exists() {
384 return Some(toml_candidate);
385 }
386
387 if !dir.pop() {
388 return None;
389 }
390 }
391 }
392
393 pub fn effective_session_dir(&self) -> Result<PathBuf> {
397 if let Some(ref dir) = self.session_dir {
398 return Ok(dir.clone());
399 }
400 Ok(Self::settings_dir()?.join("sessions"))
401 }
402
403 pub fn load() -> Result<Self> {
421 Self::load_from_cwd()
422 }
423
424 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
426 let mut settings = Settings::default();
428
429 if let Ok(global_path) = Self::settings_path() {
431 if global_path.exists() {
432 settings = Self::layer_file(&settings, &global_path)?;
433 }
434 }
435
436 if let Some(project_path) = Self::find_project_settings(dir) {
438 settings = Self::layer_file(&settings, &project_path)?;
439 }
440
441 settings.apply_env();
443
444 settings = Self::migrate(settings)?;
446
447 Ok(settings)
448 }
449
450 pub fn load_from_cwd() -> Result<Self> {
452 let cwd = env::current_dir().context("Cannot determine current directory")?;
453 Self::load_from(&cwd)
454 }
455
456 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
462 let content = fs::read_to_string(path)
463 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
464
465 let format = Self::detect_format(path);
466 let overlay: serde_json::Value = match format {
467 SettingsFormat::Toml => {
468 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
469 format!("Failed to parse TOML settings from {}", path.display())
470 })?;
471 toml_value_to_json(toml_value)
473 }
474 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
475 format!("Failed to parse JSON settings from {}", path.display())
476 })?,
477 };
478
479 let base_json =
483 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
484
485 let merged = merge_json_values(base_json, overlay);
486 let result: Settings =
487 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
488
489 Ok(result)
490 }
491
492 #[allow(dead_code)]
518 pub fn apply_env(&mut self) {
519 }
523
524 #[allow(dead_code)]
530 pub fn from_env() -> Self {
531 Self::default()
532 }
533
534 pub fn save(&self) -> Result<()> {
541 let dir = Self::settings_dir()?;
542 let path = Self::settings_path()?;
543
544 if !dir.exists() {
545 fs::create_dir_all(&dir).with_context(|| {
546 format!("Failed to create settings directory {}", dir.display())
547 })?;
548 }
549
550 let format = Self::detect_format(&path);
551 let content = Self::serialize_for_format(self, format)?;
552
553 let tmp_path = path.with_extension("tmp");
555 fs::write(&tmp_path, &content)
556 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
557 fs::rename(&tmp_path, &path)
558 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
559
560 Ok(())
561 }
562
563 pub fn save_to(&self, path: &Path) -> Result<()> {
565 if let Some(parent) = path.parent() {
566 if !parent.exists() {
567 fs::create_dir_all(parent)
568 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
569 }
570 }
571
572 let format = Self::detect_format(path);
573 let content = Self::serialize_for_format(self, format)?;
574
575 let tmp_path = path.with_extension("tmp");
577 fs::write(&tmp_path, &content)
578 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
579 fs::rename(&tmp_path, path)
580 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
581
582 Ok(())
583 }
584
585 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
589 let dir = project_dir.join(".oxi");
590
591 if !dir.exists() {
592 fs::create_dir_all(&dir).with_context(|| {
593 format!(
594 "Failed to create project settings directory {}",
595 dir.display()
596 )
597 })?;
598 }
599
600 let json_path = dir.join("settings.json");
602 let toml_path = dir.join("settings.toml");
603
604 let path = if json_path.exists() {
605 &json_path
606 } else if toml_path.exists() {
607 &toml_path
608 } else {
609 &json_path
611 };
612
613 let format = Self::detect_format(path);
614 let content = Self::serialize_for_format(self, format)?;
615
616 let tmp_path = path.with_extension("tmp");
618 fs::write(&tmp_path, &content)
619 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
620 fs::rename(&tmp_path, path)
621 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
622
623 Ok(())
624 }
625
626 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
628 match format {
629 SettingsFormat::Toml => {
630 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
631 }
632 SettingsFormat::Json => serde_json::to_string_pretty(settings)
633 .context("Failed to serialize settings to JSON"),
634 }
635 }
636
637 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
639 match format {
640 SettingsFormat::Toml => {
641 toml::from_str(content).context("Failed to parse TOML settings")
642 }
643 SettingsFormat::Json => {
644 serde_json::from_str(content).context("Failed to parse JSON settings")
645 }
646 }
647 }
648
649 pub fn merge_cli(
662 &mut self,
663 model: Option<String>,
664 provider: Option<String>,
665 enable_routing: Option<bool>,
666 prefer_cost_efficient: Option<bool>,
667 fallback_chain: Option<Vec<String>>,
668 disable_fallback: Option<bool>,
669 ) {
670 if let Some(m) = model {
671 self.last_used_model = Some(m);
672 }
673 if let Some(p) = provider {
674 self.last_used_provider = Some(p);
675 }
676 if let Some(r) = enable_routing {
677 self.enable_routing = r;
678 }
679 if let Some(p) = prefer_cost_efficient {
680 self.prefer_cost_efficient = p;
681 }
682 if let Some(fc) = fallback_chain {
683 if !fc.is_empty() {
684 self.fallback_chain = fc;
685 }
686 }
687 if let Some(df) = disable_fallback {
688 self.disable_fallback = df;
689 if df {
691 self.enable_fallback = false;
692 }
693 }
694 }
695
696 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
699 cli_model.map(String::from).or_else(|| {
700 let model = self.last_used_model.as_ref()?;
705 if model.contains('/') {
706 Some(model.clone())
708 } else if let Some(ref provider) = self.last_used_provider {
709 Some(format!("{}/{}", provider, model))
711 } else {
712 Some(model.clone())
713 }
714 })
715 }
716
717 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
720 cli_provider
721 .map(String::from)
722 .or_else(|| self.last_used_provider.clone())
723 }
724
725 pub fn effective_temperature(&self) -> Option<f64> {
728 self.default_temperature
729 .or(self.temperature.map(|t| t as f64))
730 }
731
732 pub fn effective_max_tokens(&self) -> Option<usize> {
735 self.max_response_tokens
736 .or(self.max_tokens.map(|t| t as usize))
737 }
738
739 pub fn router_profile(&self) -> Option<&str> {
741 self.router_profile.as_deref()
742 }
743
744 pub fn save_last_used(model_id: &str) {
750 if let Ok(mut settings) = Self::load() {
751 if let Some((provider, model)) = model_id.split_once('/') {
752 settings.last_used_provider = Some(provider.to_string());
753 settings.last_used_model = Some(model.to_string());
754 } else {
755 settings.last_used_model = Some(model_id.to_string());
756 }
757 let _ = settings.save();
758 }
759 }
760
761 pub fn save_theme(&mut self, name: &str) -> Result<()> {
763 self.theme = name.to_string();
764 self.save()
765 }
766
767 pub fn get_theme_name(&self) -> String {
769 if self.theme.is_empty() || self.theme == "default" {
770 "oxi_dark".to_string()
771 } else {
772 self.theme.clone()
773 }
774 }
775
776 fn migrate(settings: Settings) -> Result<Settings> {
784 let mut settings = settings;
785
786 match settings.version {
787 SETTINGS_VERSION => {
788 }
790 0 => {
791 if settings.tool_timeout_seconds == 0 {
794 settings.tool_timeout_seconds = default_tool_timeout();
795 }
796 settings.version = SETTINGS_VERSION;
797
798 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
799 }
800 1 | 2 => {
801 settings.version = SETTINGS_VERSION;
803 tracing::info!(
804 "Migrated settings from version {} to {}",
805 settings.version,
806 SETTINGS_VERSION
807 );
808 }
809 3 => {
810 if let Some(model) = settings.default_model.take() {
812 if let Some((provider, model_name)) = model.split_once('/') {
813 settings.last_used_provider = Some(provider.to_string());
814 settings.last_used_model = Some(model_name.to_string());
815 } else {
816 settings.last_used_model = Some(model);
817 }
818 }
819 settings.version = SETTINGS_VERSION;
820 tracing::info!(
821 "Migrated settings from version 3 to {} (default_model → last_used_model)",
822 SETTINGS_VERSION
823 );
824 }
825 v if v > SETTINGS_VERSION => {
826 anyhow::bail!(
828 "Settings version {} is newer than supported version {}. \
829 Please update oxi.",
830 v,
831 SETTINGS_VERSION
832 );
833 }
834 v => {
835 tracing::warn!(
837 "Unknown settings version {}, attempting migration to {}",
838 v,
839 SETTINGS_VERSION
840 );
841 settings.version = SETTINGS_VERSION;
842 }
843 }
844
845 Ok(settings)
846 }
847}
848
849#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
853pub enum SettingsFormat {
854 #[default]
856 Json,
857 Toml,
859}
860
861impl SettingsFormat {
862 pub fn extension(&self) -> &'static str {
864 match self {
865 SettingsFormat::Json => "json",
866 SettingsFormat::Toml => "toml",
867 }
868 }
869}
870
871fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
875 match toml {
876 toml::Value::String(s) => serde_json::Value::String(s),
877 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
878 toml::Value::Float(f) => serde_json::Number::from_f64(f)
879 .map(serde_json::Value::Number)
880 .unwrap_or(serde_json::Value::Null),
881 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
882 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
883 toml::Value::Array(arr) => {
884 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
885 }
886 toml::Value::Table(table) => {
887 let obj = table
888 .into_iter()
889 .map(|(k, v)| (k, toml_value_to_json(v)))
890 .collect();
891 serde_json::Value::Object(obj)
892 }
893 }
894}
895
896fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
898 match (base, override_) {
899 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
901 let mut result = base_map;
902 for (key, override_value) in override_map {
903 let base_value = result.remove(&key);
904 let merged = match base_value {
905 Some(base_v) => merge_json_values(base_v, override_value),
906 None => override_value,
907 };
908 result.insert(key, merged);
909 }
910 serde_json::Value::Object(result)
911 }
912 (_, override_) => override_,
914 }
915}
916
917pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
919 match s.to_lowercase().as_str() {
920 "off" | "none" => Some(ThinkingLevel::Off),
921 "minimal" => Some(ThinkingLevel::Minimal),
922 "low" => Some(ThinkingLevel::Low),
923 "medium" | "standard" => Some(ThinkingLevel::Medium),
924 "high" | "thorough" => Some(ThinkingLevel::High),
925 "xhigh" => Some(ThinkingLevel::XHigh),
926 _ => None,
927 }
928}
929
930#[allow(dead_code)]
932fn parse_boolish(s: &str) -> Result<bool> {
933 match s.to_lowercase().as_str() {
934 "true" | "1" | "yes" | "on" => Ok(true),
935 "false" | "0" | "no" | "off" => Ok(false),
936 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
937 }
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943 use std::io::Write as IoWrite;
944 use std::sync::Mutex;
945
946 static ENV_LOCK: Mutex<()> = Mutex::new(());
948
949 struct EnvGuard {
952 saved: Vec<(String, Option<String>)>,
953 }
954
955 impl EnvGuard {
956 fn new(vars: &[&str]) -> Self {
957 let saved = vars
958 .iter()
959 .map(|&name| {
960 let old = env::var(name).ok();
961 env::remove_var(name);
962 (name.to_string(), old)
963 })
964 .collect();
965 Self { saved }
966 }
967 }
968
969 impl Drop for EnvGuard {
970 fn drop(&mut self) {
971 for (name, old) in self.saved.drain(..) {
972 match old {
973 Some(val) => env::set_var(&name, val),
974 None => env::remove_var(&name),
975 }
976 }
977 }
978 }
979
980 #[test]
983 fn test_default_settings() {
984 let settings = Settings::default();
985 assert_eq!(settings.version, SETTINGS_VERSION);
986 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
987 assert_eq!(settings.theme, "default");
988 assert!(settings.last_used_model.is_none());
989 assert!(settings.last_used_provider.is_none());
990 assert!(settings.extensions_enabled);
991 assert!(settings.auto_compaction);
992 assert_eq!(settings.tool_timeout_seconds, 120);
993 assert!(settings.stream_responses);
994 }
995
996 #[test]
997 fn test_merge_cli() {
998 let mut settings = Settings::default();
999 settings.last_used_model = Some("gpt-4o".to_string());
1000
1001 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1002 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1003
1004 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1005 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1006
1007 settings.merge_cli(
1009 None,
1010 None,
1011 Some(true),
1012 Some(false),
1013 Some(vec!["openai/gpt-4o".to_string()]),
1014 Some(false),
1015 );
1016 assert!(settings.enable_routing);
1017 assert!(!settings.prefer_cost_efficient);
1018 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1019 assert!(!settings.disable_fallback);
1020
1021 let mut settings2 = Settings::default();
1023 settings2.merge_cli(None, None, None, None, None, Some(true));
1024 assert!(settings2.disable_fallback);
1025 assert!(!settings2.enable_fallback);
1026 }
1027
1028 #[test]
1031 fn test_layer_file_overrides() {
1032 let base = Settings::default();
1033
1034 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1035 let toml_content = r#"
1036last_used_model = "openai/gpt-4o"
1037theme = "dracula"
1038"#;
1039 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1040
1041 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1042 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1043 assert_eq!(merged.theme, "dracula");
1044 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1046 assert!(merged.extensions_enabled);
1047 }
1048
1049 #[test]
1050 fn test_layer_file_preserves_unset() {
1051 let mut base = Settings::default();
1052 base.last_used_provider = Some("deepseek".to_string());
1053
1054 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1055 let toml_content = "theme = \"monokai\"\n";
1057 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1058
1059 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1060 assert_eq!(merged.theme, "monokai");
1061 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1062 }
1063
1064 #[test]
1065 fn test_load_from_dir_with_project_config() {
1066 let _guard = EnvGuard::new(&[
1067 "OXI_MODEL",
1068 "OXI_PROVIDER",
1069 "OXI_THEME",
1070 "OXI_TOOL_TIMEOUT",
1071 "OXI_TEMPERATURE",
1072 "OXI_MAX_TOKENS",
1073 "OXI_SESSION_DIR",
1074 "OXI_STREAM",
1075 "OXI_EXTENSIONS_ENABLED",
1076 ]);
1077 let tmp = tempfile::tempdir().unwrap();
1078 let oxi_dir = tmp.path().join(".oxi");
1079 fs::create_dir_all(&oxi_dir).unwrap();
1080 let settings_path = oxi_dir.join("settings.toml");
1081 fs::write(
1083 &settings_path,
1084 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1085 )
1086 .unwrap();
1087
1088 let settings = Settings::load_from(tmp.path()).unwrap();
1089 assert_eq!(
1091 settings.last_used_model,
1092 Some("gemini-2.0-flash".to_string())
1093 );
1094 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1095 }
1096
1097 #[test]
1098 fn test_load_from_dir_no_config() {
1099 let _guard = EnvGuard::new(&[
1101 "OXI_MODEL",
1102 "OXI_PROVIDER",
1103 "OXI_THEME",
1104 "OXI_TOOL_TIMEOUT",
1105 "OXI_TEMPERATURE",
1106 "OXI_MAX_TOKENS",
1107 "OXI_SESSION_DIR",
1108 "OXI_STREAM",
1109 "OXI_EXTENSIONS_ENABLED",
1110 ]);
1111 let tmp = tempfile::tempdir().unwrap();
1112 let settings = Settings::load_from(tmp.path()).unwrap();
1113 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1115 }
1116
1117 #[test]
1120 fn test_from_env() {
1121 let _guard = EnvGuard::new(&[
1124 "OXI_MODEL",
1126 "OXI_THEME",
1127 "OXI_TOOL_TIMEOUT",
1128 "OXI_PROVIDER",
1129 "OXI_DEFAULT_MODEL",
1130 ]);
1131
1132 let settings = Settings::from_env();
1133 assert_eq!(settings.last_used_model, None);
1135 assert_eq!(settings.theme, "default");
1136 assert_eq!(settings.tool_timeout_seconds, 120);
1137 }
1138
1139 #[test]
1140 fn test_apply_env_boolish() {
1141 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1144 env::set_var("OXI_STREAM", "false");
1145 env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1146
1147 let mut settings = Settings::default();
1148 settings.apply_env();
1149 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1153
1154 #[test]
1155 fn test_apply_env_temperature() {
1156 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1158 env::set_var("OXI_TEMPERATURE", "0.7");
1159
1160 let mut settings = Settings::default();
1161 settings.apply_env();
1162 assert_eq!(settings.default_temperature, None);
1164 }
1165
1166 #[test]
1167 fn test_env_does_not_override_when_unset() {
1168 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1169 let settings = Settings::from_env();
1170 assert!(settings.last_used_model.is_none());
1171 assert!(settings.last_used_provider.is_none());
1172 }
1173
1174 #[test]
1175 fn test_parse_thinking_level() {
1176 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1177 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1178 assert_eq!(
1179 parse_thinking_level("MINIMAL"),
1180 Some(ThinkingLevel::Minimal)
1181 );
1182 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1183 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1184 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1185 assert_eq!(
1186 parse_thinking_level("Standard"),
1187 Some(ThinkingLevel::Medium)
1188 );
1189 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1190 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1191 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1192 assert_eq!(parse_thinking_level("invalid"), None);
1193 }
1194
1195 #[test]
1196 fn test_parse_boolish() {
1197 assert!(parse_boolish("true").unwrap());
1198 assert!(parse_boolish("1").unwrap());
1199 assert!(parse_boolish("yes").unwrap());
1200 assert!(parse_boolish("ON").unwrap());
1201 assert!(!parse_boolish("false").unwrap());
1202 assert!(!parse_boolish("0").unwrap());
1203 assert!(!parse_boolish("no").unwrap());
1204 assert!(!parse_boolish("OFF").unwrap());
1205 assert!(parse_boolish("maybe").is_err());
1206 }
1207
1208 #[test]
1211 fn test_effective_model_returns_last_used() {
1212 let mut settings = Settings::default();
1213 settings.last_used_model = Some("openai/gpt-4o".to_string());
1214 assert_eq!(
1215 settings.effective_model(None),
1216 Some("openai/gpt-4o".to_string())
1217 );
1218 }
1219
1220 #[test]
1221 fn test_effective_model_cli_overrides() {
1222 let mut settings = Settings::default();
1223 settings.last_used_model = Some("openai/gpt-4o".to_string());
1224 assert_eq!(
1225 settings.effective_model(Some("anthropic/claude-3")),
1226 Some("anthropic/claude-3".to_string())
1227 );
1228 }
1229
1230 #[test]
1231 fn test_effective_model_none_when_unset() {
1232 let settings = Settings::default();
1233 assert_eq!(settings.effective_model(None), None);
1234 }
1235
1236 #[test]
1237 fn test_effective_model_falls_back_to_last_used() {
1238 let mut settings = Settings::default();
1239 settings.last_used_model = Some("anthropic/claude-3".to_string());
1240 assert_eq!(
1241 settings.effective_model(None),
1242 Some("anthropic/claude-3".to_string())
1243 );
1244 }
1245
1246 #[test]
1247 fn test_effective_model_returns_none_when_nothing_set() {
1248 let settings = Settings::default();
1249 assert_eq!(settings.effective_model(None), None);
1250 }
1251
1252 #[test]
1253 fn test_effective_temperature_prefers_f64() {
1254 let mut settings = Settings::default();
1255 settings.temperature = Some(0.5);
1256 settings.default_temperature = Some(0.7);
1257 assert_eq!(settings.effective_temperature(), Some(0.7));
1258 }
1259
1260 #[test]
1261 fn test_effective_temperature_falls_back_to_f32() {
1262 let mut settings = Settings::default();
1263 settings.temperature = Some(0.5);
1264 assert_eq!(settings.effective_temperature(), Some(0.5));
1265 }
1266
1267 #[test]
1268 fn test_effective_max_tokens_prefers_usize() {
1269 let mut settings = Settings::default();
1270 settings.max_tokens = Some(1024);
1271 settings.max_response_tokens = Some(4096);
1272 assert_eq!(settings.effective_max_tokens(), Some(4096));
1273 }
1274
1275 #[test]
1276 fn test_effective_max_tokens_falls_back_to_u32() {
1277 let mut settings = Settings::default();
1278 settings.max_tokens = Some(1024);
1279 assert_eq!(settings.effective_max_tokens(), Some(1024));
1280 }
1281
1282 #[test]
1285 fn test_effective_session_dir_default() {
1286 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1287 let settings = Settings::default();
1288 let dir = settings.effective_session_dir().unwrap();
1289 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1290 }
1291
1292 #[test]
1293 fn test_effective_session_dir_from_field() {
1294 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1295 let mut settings = Settings::default();
1296 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1297 assert_eq!(
1298 settings.effective_session_dir().unwrap(),
1299 PathBuf::from("/tmp/oxi-sessions")
1300 );
1301 }
1302
1303 #[test]
1304 fn test_effective_session_dir_env_disabled() {
1305 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1308 env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
1309 let settings = Settings::default();
1310 let dir = settings.effective_session_dir().unwrap();
1312 assert!(
1313 dir.ends_with("sessions"),
1314 "expected default sessions dir, got: {:?}",
1315 dir
1316 );
1317 }
1318
1319 #[test]
1322 fn test_migration_v0_to_v1() {
1323 let mut settings = Settings::default();
1324 settings.version = 0;
1325 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1328 assert_eq!(migrated.version, SETTINGS_VERSION);
1329 assert_eq!(migrated.tool_timeout_seconds, 120);
1330 }
1331
1332 #[test]
1333 fn test_migration_already_current() {
1334 let settings = Settings::default();
1335 let migrated = Settings::migrate(settings).unwrap();
1336 assert_eq!(migrated.version, SETTINGS_VERSION);
1337 }
1338
1339 #[test]
1340 fn test_migration_v3_to_v4_splits_model() {
1341 let mut settings = Settings::default();
1342 settings.version = 3;
1343 settings.default_model = Some("openai/gpt-4o".to_string());
1344 settings.default_provider = None;
1345
1346 let migrated = Settings::migrate(settings).unwrap();
1347 assert_eq!(migrated.version, SETTINGS_VERSION);
1348 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1349 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1350 }
1351
1352 #[test]
1353 fn test_migration_v3_no_slash_keeps_model() {
1354 let mut settings = Settings::default();
1355 settings.version = 3;
1356 settings.default_model = Some("bare-model-name".to_string());
1357
1358 let migrated = Settings::migrate(settings).unwrap();
1359 assert_eq!(migrated.version, SETTINGS_VERSION);
1360 assert_eq!(
1361 migrated.last_used_model,
1362 Some("bare-model-name".to_string())
1363 );
1364 }
1365
1366 #[test]
1367 fn test_migration_future_version_fails() {
1368 let mut settings = Settings::default();
1369 settings.version = 9999;
1370 assert!(Settings::migrate(settings).is_err());
1371 }
1372
1373 #[test]
1376 fn test_save_and_load_roundtrip() {
1377 let tmp = tempfile::tempdir().unwrap();
1378 let settings_path = tmp.path().join("settings.toml");
1379
1380 let mut original = Settings::default();
1381 original.last_used_model = Some("gpt-4o".to_string());
1382 original.last_used_provider = Some("openai".to_string());
1383 original.theme = "dracula".to_string();
1384 original.tool_timeout_seconds = 60;
1385
1386 let content = toml::to_string_pretty(&original).unwrap();
1388 fs::write(&settings_path, &content).unwrap();
1389
1390 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1392 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1393
1394 assert_eq!(loaded.last_used_model, original.last_used_model);
1395 assert_eq!(loaded.theme, original.theme);
1396 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1397 }
1398
1399 #[test]
1400 fn test_toml_roundtrip_preserves_new_fields() {
1401 let mut settings = Settings::default();
1402 settings.default_temperature = Some(0.8);
1403 settings.max_response_tokens = Some(8192);
1404 settings.auto_compaction = false;
1405 settings.extensions_enabled = false;
1406 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1407
1408 let toml_str = toml::to_string_pretty(&settings).unwrap();
1409 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1410
1411 assert_eq!(parsed.default_temperature, Some(0.8));
1412 assert_eq!(parsed.max_response_tokens, Some(8192));
1413 assert!(!parsed.auto_compaction);
1414 assert!(!parsed.extensions_enabled);
1415 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1416 }
1417
1418 #[test]
1421 fn test_json_roundtrip() {
1422 let mut settings = Settings::default();
1423 settings.last_used_model = Some("gpt-4o".to_string());
1424 settings.last_used_provider = Some("openai".to_string());
1425 settings.theme = "dracula".to_string();
1426 settings.tool_timeout_seconds = 60;
1427 settings.default_temperature = Some(0.8);
1428 settings.max_response_tokens = Some(8192);
1429
1430 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1431 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1432
1433 assert_eq!(parsed.last_used_model, settings.last_used_model);
1434 assert_eq!(parsed.theme, settings.theme);
1435 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1436 assert_eq!(parsed.default_temperature, settings.default_temperature);
1437 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1438 }
1439
1440 #[test]
1441 fn test_json_serialize_for_format() {
1442 let mut settings = Settings::default();
1443 settings.last_used_model = Some("claude-3".to_string());
1444 settings.last_used_provider = Some("anthropic".to_string());
1445 settings.thinking_level = ThinkingLevel::Minimal;
1446
1447 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1448 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1449
1450 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1451 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1452 }
1453
1454 #[test]
1455 fn test_toml_serialize_for_format() {
1456 let mut settings = Settings::default();
1457 settings.last_used_model = Some("gemini-pro".to_string());
1458 settings.last_used_provider = Some("google".to_string());
1459 settings.thinking_level = ThinkingLevel::High;
1460
1461 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1462 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1463
1464 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1465 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1466 }
1467
1468 #[test]
1469 fn test_parse_from_str_json() {
1470 let json_content = r#"{
1471 "last_used_model": "gpt-4",
1472 "last_used_provider": "openai",
1473 "theme": "nord",
1474 "tool_timeout_seconds": 90
1475 }"#;
1476
1477 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1478 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1479 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1480 assert_eq!(settings.theme, "nord");
1481 assert_eq!(settings.tool_timeout_seconds, 90);
1482 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1484 assert!(settings.extensions_enabled);
1485 }
1486
1487 #[test]
1488 fn test_parse_from_str_toml() {
1489 let toml_content = r#"
1490last_used_model = "claude-opus"
1491last_used_provider = "anthropic"
1492theme = "monokai"
1493tool_timeout_seconds = 45
1494"#;
1495
1496 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1497 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1498 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1499 assert_eq!(settings.theme, "monokai");
1500 assert_eq!(settings.tool_timeout_seconds, 45);
1501 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1502 }
1503
1504 #[test]
1505 fn test_layer_file_json() {
1506 let base = Settings::default();
1507
1508 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1509 let json_content = r#"{
1510 "last_used_model": "gpt-4o",
1511 "last_used_provider": "openai",
1512 "theme": "dracula",
1513 "auto_compaction": false
1514 }"#;
1515 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1516
1517 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1518 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1519 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1520 assert_eq!(merged.theme, "dracula");
1521 assert!(!merged.auto_compaction);
1522 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1524 assert!(merged.extensions_enabled);
1525 assert_eq!(merged.tool_timeout_seconds, 120);
1526 }
1527
1528 #[test]
1529 fn test_layer_file_json_preserves_unset() {
1530 let mut base = Settings::default();
1531 base.last_used_provider = Some("deepseek".to_string());
1532
1533 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1534 let json_content = r#"{ "theme": "nord" }"#;
1535 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1536
1537 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1538 assert_eq!(merged.theme, "nord");
1539 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1540 }
1541
1542 #[test]
1543 fn test_save_to_json() {
1544 let tmp = tempfile::tempdir().unwrap();
1545 let settings_path = tmp.path().join("settings.json");
1546
1547 let mut settings = Settings::default();
1548 settings.last_used_model = Some("gpt-4o".to_string());
1549 settings.last_used_provider = Some("openai".to_string());
1550 settings.theme = "dracula".to_string();
1551 settings.tool_timeout_seconds = 60;
1552
1553 settings.save_to(&settings_path).unwrap();
1554
1555 let content = fs::read_to_string(&settings_path).unwrap();
1557 let parsed: Settings = serde_json::from_str(&content).unwrap();
1558 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1559 assert_eq!(parsed.theme, "dracula");
1560 assert_eq!(parsed.tool_timeout_seconds, 60);
1561 }
1562
1563 #[test]
1564 fn test_save_to_toml() {
1565 let tmp = tempfile::tempdir().unwrap();
1566 let settings_path = tmp.path().join("settings.toml");
1567
1568 let mut settings = Settings::default();
1569 settings.last_used_model = Some("gemini-pro".to_string());
1570 settings.last_used_provider = Some("google".to_string());
1571 settings.theme = "monokai".to_string();
1572 settings.tool_timeout_seconds = 90;
1573
1574 settings.save_to(&settings_path).unwrap();
1575
1576 let content = fs::read_to_string(&settings_path).unwrap();
1578 let parsed: Settings = toml::from_str(&content).unwrap();
1579 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1580 assert_eq!(parsed.theme, "monokai");
1581 assert_eq!(parsed.tool_timeout_seconds, 90);
1582 }
1583
1584 #[test]
1585 fn test_load_from_dir_with_json_project_config() {
1586 let _guard = EnvGuard::new(&[
1587 "OXI_MODEL",
1588 "OXI_PROVIDER",
1589 "OXI_THEME",
1590 "OXI_TOOL_TIMEOUT",
1591 "OXI_TEMPERATURE",
1592 "OXI_MAX_TOKENS",
1593 "OXI_SESSION_DIR",
1594 "OXI_STREAM",
1595 "OXI_EXTENSIONS_ENABLED",
1596 ]);
1597 let tmp = tempfile::tempdir().unwrap();
1598 let oxi_dir = tmp.path().join(".oxi");
1599 fs::create_dir_all(&oxi_dir).unwrap();
1600 let settings_path = oxi_dir.join("settings.json");
1601 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1603 fs::write(&settings_path, json_content).unwrap();
1604
1605 let settings = Settings::load_from(tmp.path()).unwrap();
1606 assert_eq!(
1608 settings.last_used_model,
1609 Some("gemini-2.0-flash".to_string())
1610 );
1611 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1612 }
1613
1614 #[test]
1615 fn test_find_project_settings_json_priority() {
1616 let tmp = tempfile::tempdir().unwrap();
1617 let oxi_dir = tmp.path().join(".oxi");
1618 fs::create_dir_all(&oxi_dir).unwrap();
1619
1620 let json_path = oxi_dir.join("settings.json");
1622 let toml_path = oxi_dir.join("settings.toml");
1623 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1624 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1625
1626 let found = Settings::find_project_settings(tmp.path());
1628 assert!(found.is_some());
1629 assert_eq!(
1630 found.unwrap().file_name().unwrap().to_str().unwrap(),
1631 "settings.json"
1632 );
1633 }
1634
1635 #[test]
1636 fn test_find_project_settings_json_only() {
1637 let tmp = tempfile::tempdir().unwrap();
1638 let oxi_dir = tmp.path().join(".oxi");
1639 fs::create_dir_all(&oxi_dir).unwrap();
1640
1641 let json_path = oxi_dir.join("settings.json");
1642 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1643
1644 let found = Settings::find_project_settings(tmp.path());
1645 assert!(found.is_some());
1646 assert_eq!(
1647 found.unwrap().file_name().unwrap().to_str().unwrap(),
1648 "settings.json"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_find_project_settings_toml_fallback() {
1654 let tmp = tempfile::tempdir().unwrap();
1655 let oxi_dir = tmp.path().join(".oxi");
1656 fs::create_dir_all(&oxi_dir).unwrap();
1657
1658 let toml_path = oxi_dir.join("settings.toml");
1659 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1660
1661 let found = Settings::find_project_settings(tmp.path());
1662 assert!(found.is_some());
1663 assert_eq!(
1664 found.unwrap().file_name().unwrap().to_str().unwrap(),
1665 "settings.toml"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_detect_format() {
1671 let json_path = PathBuf::from("/test/settings.json");
1672 let toml_path = PathBuf::from("/test/settings.toml");
1673 let unknown_path = PathBuf::from("/test/settings");
1674
1675 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1676 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1677 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1678 }
1680
1681 #[test]
1682 fn test_settings_format_extension() {
1683 assert_eq!(SettingsFormat::Json.extension(), "json");
1684 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1685 }
1686
1687 #[test]
1688 fn test_layer_json_over_toml() {
1689 let tmp = tempfile::tempdir().unwrap();
1691 let oxi_dir = tmp.path().join(".oxi");
1692 fs::create_dir_all(&oxi_dir).unwrap();
1693
1694 let json_path = oxi_dir.join("settings.json");
1695 let toml_path = oxi_dir.join("settings.toml");
1696
1697 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1699 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1701
1702 let settings = Settings::load_from(tmp.path()).unwrap();
1704 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1705 }
1706
1707 #[test]
1708 fn test_mixed_format_loading() {
1709 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1711 let toml_content = r#"
1712last_used_model = "loaded-via-toml"
1713theme = "loaded-theme"
1714stream_responses = false
1715"#;
1716 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1717
1718 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1719 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
1720 assert_eq!(merged.theme, "loaded-theme");
1721 assert!(!merged.stream_responses);
1722 }
1723
1724 #[test]
1725 fn test_merge_json_values() {
1726 let base = serde_json::json!({
1727 "version": 1,
1728 "theme": "default",
1729 "extensions": ["ext1"],
1730 "nested": {
1731 "a": 1,
1732 "b": 2
1733 }
1734 });
1735
1736 let override_ = serde_json::json!({
1737 "version": 2,
1738 "theme": "dark",
1739 "extensions": ["ext2"],
1740 "nested": {
1741 "b": 20,
1742 "c": 30
1743 }
1744 });
1745
1746 let merged = merge_json_values(base, override_);
1747
1748 assert_eq!(merged["version"], 2);
1749 assert_eq!(merged["theme"], "dark");
1750 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1752 assert_eq!(merged["nested"]["a"], 1);
1754 assert_eq!(merged["nested"]["b"], 20);
1755 assert_eq!(merged["nested"]["c"], 30);
1756 }
1757
1758 #[test]
1759 fn test_save_project_preserves_existing_format() {
1760 let tmp = tempfile::tempdir().unwrap();
1761 let oxi_dir = tmp.path().join(".oxi");
1762 fs::create_dir_all(&oxi_dir).unwrap();
1763
1764 let toml_path = oxi_dir.join("settings.toml");
1766 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1767
1768 let mut settings = Settings::default();
1769 settings.theme = "new-theme".to_string();
1770 settings.save_project(tmp.path()).unwrap();
1771
1772 let content = fs::read_to_string(&toml_path).unwrap();
1774 assert!(content.contains("new-theme"));
1775 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1776 }
1777
1778 #[test]
1779 fn test_save_project_creates_json_by_default() {
1780 let tmp = tempfile::tempdir().unwrap();
1781 let oxi_dir = tmp.path().join(".oxi");
1782 fs::create_dir_all(&oxi_dir).unwrap();
1783 let mut settings = Settings::default();
1786 settings.theme = "json-theme".to_string();
1787 settings.save_project(tmp.path()).unwrap();
1788
1789 let json_path = oxi_dir.join("settings.json");
1791 assert!(json_path.exists());
1792 let content = fs::read_to_string(&json_path).unwrap();
1793 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1794 assert!(content.contains("json-theme"));
1795 }
1796
1797 #[test]
1800 fn test_custom_provider_default_api() {
1801 use super::CustomProvider;
1802 let cp = CustomProvider {
1803 name: "test".to_string(),
1804 base_url: "https://api.test.com/v1".to_string(),
1805 api_key_env: "TEST_API_KEY".to_string(),
1806 api: super::default_custom_provider_api(),
1807 };
1808 assert_eq!(cp.api, "openai-completions");
1809 }
1810
1811 #[test]
1812 fn test_custom_provider_toml_deserialize() {
1813 let toml_content = r#"
1814[[custom_providers]]
1815name = "minimax"
1816base_url = "https://api.minimax.chat/v1"
1817api_key_env = "MINIMAX_API_KEY"
1818api = "openai-completions"
1819
1820[[custom_providers]]
1821name = "zai"
1822base_url = "https://api.z.ai/v1"
1823api_key_env = "ZAI_API_KEY"
1824api = "openai-responses"
1825"#;
1826 let settings: Settings = toml::from_str(toml_content).unwrap();
1827 assert_eq!(settings.custom_providers.len(), 2);
1828 assert_eq!(settings.custom_providers[0].name, "minimax");
1829 assert_eq!(
1830 settings.custom_providers[0].base_url,
1831 "https://api.minimax.chat/v1"
1832 );
1833 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
1834 assert_eq!(settings.custom_providers[0].api, "openai-completions");
1835 assert_eq!(settings.custom_providers[1].name, "zai");
1836 assert_eq!(settings.custom_providers[1].api, "openai-responses");
1837 }
1838
1839 #[test]
1840 fn test_custom_provider_json_deserialize() {
1841 let json_content = r#"{
1842 "custom_providers": [
1843 {
1844 "name": "minimax",
1845 "base_url": "https://api.minimax.chat/v1",
1846 "api_key_env": "MINIMAX_API_KEY",
1847 "api": "openai-completions"
1848 }
1849 ]
1850 }"#;
1851 let settings: Settings = serde_json::from_str(json_content).unwrap();
1852 assert_eq!(settings.custom_providers.len(), 1);
1853 assert_eq!(settings.custom_providers[0].name, "minimax");
1854 }
1855
1856 #[test]
1857 fn test_custom_provider_toml_roundtrip() {
1858 let mut settings = Settings::default();
1859 settings.custom_providers.push(super::CustomProvider {
1860 name: "test".to_string(),
1861 base_url: "https://api.test.com/v1".to_string(),
1862 api_key_env: "TEST_API_KEY".to_string(),
1863 api: "openai-completions".to_string(),
1864 });
1865
1866 let toml_str = toml::to_string_pretty(&settings).unwrap();
1867 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1868 assert_eq!(parsed.custom_providers.len(), 1);
1869 assert_eq!(parsed.custom_providers[0].name, "test");
1870 assert_eq!(
1871 parsed.custom_providers[0].base_url,
1872 "https://api.test.com/v1"
1873 );
1874 }
1875
1876 #[test]
1877 fn test_custom_provider_defaults_empty() {
1878 let settings = Settings::default();
1879 assert!(settings.custom_providers.is_empty());
1880 }
1881
1882 #[test]
1883 fn test_custom_provider_layer_file() {
1884 let base = Settings::default();
1885
1886 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1887 let toml_content = r#"
1888[[custom_providers]]
1889name = "my-provider"
1890base_url = "https://api.my-provider.com/v1"
1891api_key_env = "MY_PROVIDER_API_KEY"
1892"#;
1893 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1894
1895 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1896 assert_eq!(merged.custom_providers.len(), 1);
1897 assert_eq!(merged.custom_providers[0].name, "my-provider");
1898 assert_eq!(merged.custom_providers[0].api, "openai-completions");
1900 }
1901}