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 pub default_model: Option<String>,
86
87 pub default_provider: Option<String>,
89
90 #[serde(default)]
92 pub last_used_model: Option<String>,
93
94 #[serde(default)]
96 pub last_used_provider: Option<String>,
97
98 pub max_tokens: Option<u32>,
100
101 pub temperature: Option<f32>,
103
104 pub default_temperature: Option<f64>,
106
107 pub max_response_tokens: Option<usize>,
109
110 #[serde(default = "default_session_history_size")]
113 pub session_history_size: usize,
114
115 pub session_dir: Option<PathBuf>,
117
118 #[serde(default = "default_true")]
121 pub stream_responses: bool,
122
123 #[serde(default = "default_true")]
125 pub extensions_enabled: bool,
126
127 #[serde(default = "default_true")]
129 pub auto_compaction: bool,
130
131 #[serde(default)]
134 pub disabled_tools: Vec<String>,
135
136 #[serde(default = "default_tool_timeout")]
139 pub tool_timeout_seconds: u64,
140
141 #[serde(default)]
144 pub extensions: Vec<String>,
145
146 #[serde(default)]
148 pub skills: Vec<String>,
149
150 #[serde(default)]
152 pub prompts: Vec<String>,
153
154 #[serde(default)]
156 pub themes: Vec<String>,
157
158 #[serde(default)]
161 pub custom_providers: Vec<CustomProvider>,
162
163 #[serde(default)]
168 pub dynamic_models: HashMap<String, Vec<String>>,
169
170 #[serde(default = "default_false")]
173 pub enable_routing: bool,
174
175 #[serde(default)]
177 pub router_profile: Option<String>,
178
179 #[serde(default = "default_true")]
181 pub prefer_cost_efficient: bool,
182
183 #[serde(default)]
185 pub fallback_chain: Vec<String>,
186
187 #[serde(default = "default_true")]
189 pub enable_fallback: bool,
190
191 #[serde(default)]
193 pub disable_fallback: bool,
194
195 #[serde(default = "default_circuit_failure_threshold")]
197 pub circuit_breaker_failure_threshold: u32,
198
199 #[serde(default = "default_circuit_open_duration_secs")]
201 pub circuit_breaker_open_duration_secs: u64,
202
203 #[serde(default)]
208 pub keybindings: HashMap<String, Vec<String>>,
209}
210
211fn default_theme() -> String {
212 "default".to_string()
213}
214
215fn default_thinking_level() -> ThinkingLevel {
216 ThinkingLevel::Medium
217}
218
219fn default_session_history_size() -> usize {
220 100
221}
222
223fn default_true() -> bool {
224 true
225}
226
227fn default_false() -> bool {
228 false
229}
230
231fn default_circuit_failure_threshold() -> u32 {
232 5
233}
234
235fn default_circuit_open_duration_secs() -> u64 {
236 30
237}
238
239fn default_tool_timeout() -> u64 {
240 120
241}
242
243impl Default for Settings {
244 fn default() -> Self {
245 Self {
246 version: SETTINGS_VERSION,
247 thinking_level: ThinkingLevel::Medium,
248 theme: default_theme(),
249 default_model: None,
250 default_provider: None,
251 last_used_model: None,
252 last_used_provider: None,
253 max_tokens: None,
254 temperature: None,
255 default_temperature: None,
256 max_response_tokens: None,
257 session_history_size: default_session_history_size(),
258 session_dir: None,
259 stream_responses: true,
260 extensions_enabled: true,
261 auto_compaction: true,
262 disabled_tools: Vec::new(),
263 tool_timeout_seconds: default_tool_timeout(),
264 extensions: Vec::new(),
265 skills: Vec::new(),
266 prompts: Vec::new(),
267 themes: Vec::new(),
268 custom_providers: Vec::new(),
269 dynamic_models: HashMap::new(),
270 enable_routing: false,
272 router_profile: None,
273 prefer_cost_efficient: true,
274 fallback_chain: Vec::new(),
275 enable_fallback: true,
276 disable_fallback: false,
277 circuit_breaker_failure_threshold: 5,
278 circuit_breaker_open_duration_secs: 30,
279 keybindings: HashMap::new(),
280 }
281 }
282}
283
284impl Settings {
285 pub fn settings_dir() -> Result<PathBuf> {
289 let base = dirs::home_dir().context("Cannot determine home directory")?;
290 Ok(base.join(".oxi"))
291 }
292
293 pub fn settings_toml_path() -> Result<PathBuf> {
295 Ok(Self::settings_dir()?.join("settings.toml"))
296 }
297
298 pub fn settings_json_path() -> Result<PathBuf> {
300 Ok(Self::settings_dir()?.join("settings.json"))
301 }
302
303 pub fn settings_path() -> Result<PathBuf> {
310 let json_path = Self::settings_json_path()?;
311 let toml_path = Self::settings_toml_path()?;
312
313 if json_path.exists() && toml_path.exists() {
314 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
316 return Ok(json_path);
317 }
318
319 if json_path.exists() {
320 return Ok(json_path);
321 }
322
323 if toml_path.exists() {
324 return Ok(toml_path);
325 }
326
327 Ok(json_path)
329 }
330
331 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
336 let json_path = Self::settings_json_path()?;
337 let toml_path = Self::settings_toml_path()?;
338
339 let (primary, secondary) = if prefer_json {
340 (&json_path, &toml_path)
341 } else {
342 (&toml_path, &json_path)
343 };
344
345 if primary.exists() {
346 return Ok(primary.clone());
347 }
348
349 if secondary.exists() {
350 return Ok(secondary.clone());
351 }
352
353 Ok(primary.clone())
355 }
356
357 pub fn detect_format(path: &Path) -> SettingsFormat {
359 match path.extension().and_then(|e| e.to_str()) {
360 Some("json") => SettingsFormat::Json,
361 Some("toml") => SettingsFormat::Toml,
362 _ => SettingsFormat::Json, }
364 }
365
366 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
371 let mut dir = start_dir.to_path_buf();
372 loop {
373 let json_candidate = dir.join(".oxi").join("settings.json");
375 if json_candidate.exists() {
376 return Some(json_candidate);
377 }
378
379 let toml_candidate = dir.join(".oxi").join("settings.toml");
380 if toml_candidate.exists() {
381 return Some(toml_candidate);
382 }
383
384 if !dir.pop() {
385 return None;
386 }
387 }
388 }
389
390 pub fn effective_session_dir(&self) -> Result<PathBuf> {
394 if let Some(ref dir) = self.session_dir {
395 return Ok(dir.clone());
396 }
397 Ok(Self::settings_dir()?.join("sessions"))
398 }
399
400 pub fn load() -> Result<Self> {
418 Self::load_from_cwd()
419 }
420
421 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
423 let mut settings = Settings::default();
425
426 if let Ok(global_path) = Self::settings_path() {
428 if global_path.exists() {
429 settings = Self::layer_file(&settings, &global_path)?;
430 }
431 }
432
433 if let Some(project_path) = Self::find_project_settings(dir) {
435 settings = Self::layer_file(&settings, &project_path)?;
436 }
437
438 settings.apply_env();
440
441 settings = Self::migrate(settings)?;
443
444 Ok(settings)
445 }
446
447 pub fn load_from_cwd() -> Result<Self> {
449 let cwd = env::current_dir().context("Cannot determine current directory")?;
450 Self::load_from(&cwd)
451 }
452
453 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
459 let content = fs::read_to_string(path)
460 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
461
462 let format = Self::detect_format(path);
463 let overlay: serde_json::Value = match format {
464 SettingsFormat::Toml => {
465 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
466 format!("Failed to parse TOML settings from {}", path.display())
467 })?;
468 toml_value_to_json(toml_value)
470 }
471 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
472 format!("Failed to parse JSON settings from {}", path.display())
473 })?,
474 };
475
476 let base_json =
480 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
481
482 let merged = merge_json_values(base_json, overlay);
483 let result: Settings =
484 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
485
486 Ok(result)
487 }
488
489 #[allow(dead_code)]
515 pub fn apply_env(&mut self) {
516 }
520
521 #[allow(dead_code)]
527 pub fn from_env() -> Self {
528 Self::default()
529 }
530
531 pub fn save(&self) -> Result<()> {
538 let dir = Self::settings_dir()?;
539 let path = Self::settings_path()?;
540
541 if !dir.exists() {
542 fs::create_dir_all(&dir).with_context(|| {
543 format!("Failed to create settings directory {}", dir.display())
544 })?;
545 }
546
547 let format = Self::detect_format(&path);
548 let content = Self::serialize_for_format(self, format)?;
549
550 let tmp_path = path.with_extension("tmp");
552 fs::write(&tmp_path, &content)
553 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
554 fs::rename(&tmp_path, &path)
555 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
556
557 Ok(())
558 }
559
560 pub fn save_to(&self, path: &Path) -> Result<()> {
562 if let Some(parent) = path.parent() {
563 if !parent.exists() {
564 fs::create_dir_all(parent)
565 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
566 }
567 }
568
569 let format = Self::detect_format(path);
570 let content = Self::serialize_for_format(self, format)?;
571
572 let tmp_path = path.with_extension("tmp");
574 fs::write(&tmp_path, &content)
575 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
576 fs::rename(&tmp_path, path)
577 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
578
579 Ok(())
580 }
581
582 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
586 let dir = project_dir.join(".oxi");
587
588 if !dir.exists() {
589 fs::create_dir_all(&dir).with_context(|| {
590 format!(
591 "Failed to create project settings directory {}",
592 dir.display()
593 )
594 })?;
595 }
596
597 let json_path = dir.join("settings.json");
599 let toml_path = dir.join("settings.toml");
600
601 let path = if json_path.exists() {
602 &json_path
603 } else if toml_path.exists() {
604 &toml_path
605 } else {
606 &json_path
608 };
609
610 let format = Self::detect_format(path);
611 let content = Self::serialize_for_format(self, format)?;
612
613 let tmp_path = path.with_extension("tmp");
615 fs::write(&tmp_path, &content)
616 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
617 fs::rename(&tmp_path, path)
618 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
619
620 Ok(())
621 }
622
623 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
625 match format {
626 SettingsFormat::Toml => {
627 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
628 }
629 SettingsFormat::Json => serde_json::to_string_pretty(settings)
630 .context("Failed to serialize settings to JSON"),
631 }
632 }
633
634 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
636 match format {
637 SettingsFormat::Toml => {
638 toml::from_str(content).context("Failed to parse TOML settings")
639 }
640 SettingsFormat::Json => {
641 serde_json::from_str(content).context("Failed to parse JSON settings")
642 }
643 }
644 }
645
646 pub fn merge_cli(
659 &mut self,
660 model: Option<String>,
661 provider: Option<String>,
662 enable_routing: Option<bool>,
663 prefer_cost_efficient: Option<bool>,
664 fallback_chain: Option<Vec<String>>,
665 disable_fallback: Option<bool>,
666 ) {
667 if let Some(m) = model {
668 self.default_model = Some(m);
669 }
670 if let Some(p) = provider {
671 self.default_provider = Some(p);
672 }
673 if let Some(r) = enable_routing {
674 self.enable_routing = r;
675 }
676 if let Some(p) = prefer_cost_efficient {
677 self.prefer_cost_efficient = p;
678 }
679 if let Some(fc) = fallback_chain {
680 if !fc.is_empty() {
681 self.fallback_chain = fc;
682 }
683 }
684 if let Some(df) = disable_fallback {
685 self.disable_fallback = df;
686 if df {
688 self.enable_fallback = false;
689 }
690 }
691 }
692
693 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
697 cli_model
698 .map(String::from)
699 .or_else(|| {
700 if let (Some(provider), Some(model)) = (&self.default_provider, &self.default_model)
702 {
703 Some(format!("{}/{}", provider, model))
704 } else {
705 self.default_model.clone()
706 }
707 })
708 .or_else(|| self.last_used_model.clone())
709 }
710
711 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
714 cli_provider
715 .map(String::from)
716 .or_else(|| self.default_provider.clone())
717 .or_else(|| self.last_used_provider.clone())
718 }
719
720 pub fn effective_temperature(&self) -> Option<f64> {
723 self.default_temperature
724 .or(self.temperature.map(|t| t as f64))
725 }
726
727 pub fn effective_max_tokens(&self) -> Option<usize> {
730 self.max_response_tokens
731 .or(self.max_tokens.map(|t| t as usize))
732 }
733
734 pub fn router_profile(&self) -> Option<&str> {
736 self.router_profile.as_deref()
737 }
738
739 pub fn save_last_used(model_id: &str) {
743 if let Ok(mut settings) = Self::load() {
744 let parts: Vec<&str> = model_id.splitn(2, '/').collect();
745 settings.last_used_model = Some(model_id.to_string());
746 settings.last_used_provider = parts.first().map(|s| s.to_string());
747 let _ = settings.save();
748 }
749 }
750
751 pub fn save_theme(&mut self, name: &str) -> Result<()> {
753 self.theme = name.to_string();
754 self.save()
755 }
756
757 pub fn get_theme_name(&self) -> String {
759 if self.theme.is_empty() || self.theme == "default" {
760 "oxi_dark".to_string()
761 } else {
762 self.theme.clone()
763 }
764 }
765
766 fn migrate(settings: Settings) -> Result<Settings> {
774 let mut settings = settings;
775
776 match settings.version {
777 SETTINGS_VERSION => {
778 }
780 0 => {
781 if settings.tool_timeout_seconds == 0 {
784 settings.tool_timeout_seconds = default_tool_timeout();
785 }
786 settings.version = SETTINGS_VERSION;
787
788 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
789 }
790 1 | 2 => {
791 settings.version = SETTINGS_VERSION;
793 tracing::info!(
794 "Migrated settings from version {} to {}",
795 settings.version,
796 SETTINGS_VERSION
797 );
798 }
799 3 => {
800 if let Some(model) = settings.default_model.take() {
802 if let Some((provider, model_name)) = model.split_once('/') {
803 settings.default_provider = Some(provider.to_string());
804 settings.default_model = Some(model_name.to_string());
805 } else {
806 settings.default_model = Some(model);
808 }
809 }
810 settings.version = SETTINGS_VERSION;
811 tracing::info!(
812 "Migrated settings from version 3 to {} (split default_model into provider + model)",
813 SETTINGS_VERSION
814 );
815 }
816 v if v > SETTINGS_VERSION => {
817 anyhow::bail!(
819 "Settings version {} is newer than supported version {}. \
820 Please update oxi.",
821 v,
822 SETTINGS_VERSION
823 );
824 }
825 v => {
826 tracing::warn!(
828 "Unknown settings version {}, attempting migration to {}",
829 v,
830 SETTINGS_VERSION
831 );
832 settings.version = SETTINGS_VERSION;
833 }
834 }
835
836 Ok(settings)
837 }
838}
839
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
844pub enum SettingsFormat {
845 #[default]
847 Json,
848 Toml,
850}
851
852impl SettingsFormat {
853 pub fn extension(&self) -> &'static str {
855 match self {
856 SettingsFormat::Json => "json",
857 SettingsFormat::Toml => "toml",
858 }
859 }
860}
861
862fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
866 match toml {
867 toml::Value::String(s) => serde_json::Value::String(s),
868 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
869 toml::Value::Float(f) => serde_json::Number::from_f64(f)
870 .map(serde_json::Value::Number)
871 .unwrap_or(serde_json::Value::Null),
872 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
873 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
874 toml::Value::Array(arr) => {
875 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
876 }
877 toml::Value::Table(table) => {
878 let obj = table
879 .into_iter()
880 .map(|(k, v)| (k, toml_value_to_json(v)))
881 .collect();
882 serde_json::Value::Object(obj)
883 }
884 }
885}
886
887fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
889 match (base, override_) {
890 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
892 let mut result = base_map;
893 for (key, override_value) in override_map {
894 let base_value = result.remove(&key);
895 let merged = match base_value {
896 Some(base_v) => merge_json_values(base_v, override_value),
897 None => override_value,
898 };
899 result.insert(key, merged);
900 }
901 serde_json::Value::Object(result)
902 }
903 (_, override_) => override_,
905 }
906}
907
908pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
910 match s.to_lowercase().as_str() {
911 "off" | "none" => Some(ThinkingLevel::Off),
912 "minimal" => Some(ThinkingLevel::Minimal),
913 "low" => Some(ThinkingLevel::Low),
914 "medium" | "standard" => Some(ThinkingLevel::Medium),
915 "high" | "thorough" => Some(ThinkingLevel::High),
916 "xhigh" => Some(ThinkingLevel::XHigh),
917 _ => None,
918 }
919}
920
921#[allow(dead_code)]
923fn parse_boolish(s: &str) -> Result<bool> {
924 match s.to_lowercase().as_str() {
925 "true" | "1" | "yes" | "on" => Ok(true),
926 "false" | "0" | "no" | "off" => Ok(false),
927 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934 use std::io::Write as IoWrite;
935 use std::sync::Mutex;
936
937 static ENV_LOCK: Mutex<()> = Mutex::new(());
939
940 struct EnvGuard {
943 saved: Vec<(String, Option<String>)>,
944 }
945
946 impl EnvGuard {
947 fn new(vars: &[&str]) -> Self {
948 let saved = vars
949 .iter()
950 .map(|&name| {
951 let old = env::var(name).ok();
952 env::remove_var(name);
953 (name.to_string(), old)
954 })
955 .collect();
956 Self { saved }
957 }
958 }
959
960 impl Drop for EnvGuard {
961 fn drop(&mut self) {
962 for (name, old) in self.saved.drain(..) {
963 match old {
964 Some(val) => env::set_var(&name, val),
965 None => env::remove_var(&name),
966 }
967 }
968 }
969 }
970
971 #[test]
974 fn test_default_settings() {
975 let settings = Settings::default();
976 assert_eq!(settings.version, SETTINGS_VERSION);
977 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
978 assert_eq!(settings.theme, "default");
979 assert!(settings.default_model.is_none());
980 assert!(settings.default_provider.is_none());
981 assert!(settings.extensions_enabled);
982 assert!(settings.auto_compaction);
983 assert_eq!(settings.tool_timeout_seconds, 120);
984 assert!(settings.stream_responses);
985 }
986
987 #[test]
988 fn test_merge_cli() {
989 let mut settings = Settings::default();
990 settings.default_model = Some("gpt-4o".to_string());
991
992 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
993 assert_eq!(settings.default_model, Some("claude".to_string()));
994
995 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
996 assert_eq!(settings.default_provider, Some("google".to_string()));
997
998 settings.merge_cli(
1000 None,
1001 None,
1002 Some(true),
1003 Some(false),
1004 Some(vec!["openai/gpt-4o".to_string()]),
1005 Some(false),
1006 );
1007 assert!(settings.enable_routing);
1008 assert!(!settings.prefer_cost_efficient);
1009 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1010 assert!(!settings.disable_fallback);
1011
1012 let mut settings2 = Settings::default();
1014 settings2.merge_cli(None, None, None, None, None, Some(true));
1015 assert!(settings2.disable_fallback);
1016 assert!(!settings2.enable_fallback);
1017 }
1018
1019 #[test]
1022 fn test_layer_file_overrides() {
1023 let base = Settings::default();
1024
1025 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1026 let toml_content = r#"
1027default_model = "openai/gpt-4o"
1028theme = "dracula"
1029"#;
1030 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1031
1032 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1033 assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
1034 assert_eq!(merged.theme, "dracula");
1035 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1037 assert!(merged.extensions_enabled);
1038 }
1039
1040 #[test]
1041 fn test_layer_file_preserves_unset() {
1042 let mut base = Settings::default();
1043 base.default_provider = Some("deepseek".to_string());
1044
1045 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1046 let toml_content = "theme = \"monokai\"\n";
1048 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1049
1050 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1051 assert_eq!(merged.theme, "monokai");
1052 assert_eq!(merged.default_provider, Some("deepseek".to_string()));
1053 }
1054
1055 #[test]
1056 fn test_load_from_dir_with_project_config() {
1057 let _guard = EnvGuard::new(&[
1058 "OXI_MODEL",
1059 "OXI_PROVIDER",
1060 "OXI_THEME",
1061 "OXI_TOOL_TIMEOUT",
1062 "OXI_TEMPERATURE",
1063 "OXI_MAX_TOKENS",
1064 "OXI_SESSION_DIR",
1065 "OXI_STREAM",
1066 "OXI_EXTENSIONS_ENABLED",
1067 ]);
1068 let tmp = tempfile::tempdir().unwrap();
1069 let oxi_dir = tmp.path().join(".oxi");
1070 fs::create_dir_all(&oxi_dir).unwrap();
1071 let settings_path = oxi_dir.join("settings.toml");
1072 fs::write(
1074 &settings_path,
1075 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1076 )
1077 .unwrap();
1078
1079 let settings = Settings::load_from(tmp.path()).unwrap();
1080 assert_eq!(settings.default_model, Some("gemini-2.0-flash".to_string()));
1082 assert_eq!(settings.default_provider, Some("google".to_string()));
1083 }
1084
1085 #[test]
1086 fn test_load_from_dir_no_config() {
1087 let _guard = EnvGuard::new(&[
1089 "OXI_MODEL",
1090 "OXI_PROVIDER",
1091 "OXI_THEME",
1092 "OXI_TOOL_TIMEOUT",
1093 "OXI_TEMPERATURE",
1094 "OXI_MAX_TOKENS",
1095 "OXI_SESSION_DIR",
1096 "OXI_STREAM",
1097 "OXI_EXTENSIONS_ENABLED",
1098 ]);
1099 let tmp = tempfile::tempdir().unwrap();
1100 let settings = Settings::load_from(tmp.path()).unwrap();
1101 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1103 }
1104
1105 #[test]
1108 fn test_from_env() {
1109 let _guard = EnvGuard::new(&[
1112 "OXI_MODEL",
1114 "OXI_THEME",
1115 "OXI_TOOL_TIMEOUT",
1116 "OXI_PROVIDER",
1117 "OXI_DEFAULT_MODEL",
1118 ]);
1119
1120 let settings = Settings::from_env();
1121 assert_eq!(settings.default_model, None);
1123 assert_eq!(settings.theme, "default");
1124 assert_eq!(settings.tool_timeout_seconds, 120);
1125 }
1126
1127 #[test]
1128 fn test_apply_env_boolish() {
1129 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1132 env::set_var("OXI_STREAM", "false");
1133 env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1134
1135 let mut settings = Settings::default();
1136 settings.apply_env();
1137 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1141
1142 #[test]
1143 fn test_apply_env_temperature() {
1144 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1146 env::set_var("OXI_TEMPERATURE", "0.7");
1147
1148 let mut settings = Settings::default();
1149 settings.apply_env();
1150 assert_eq!(settings.default_temperature, None);
1152 }
1153
1154 #[test]
1155 fn test_env_does_not_override_when_unset() {
1156 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1157 let settings = Settings::from_env();
1158 assert!(settings.default_model.is_none());
1159 assert!(settings.default_provider.is_none());
1160 }
1161
1162 #[test]
1165 fn test_parse_thinking_level() {
1166 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1167 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1168 assert_eq!(
1169 parse_thinking_level("MINIMAL"),
1170 Some(ThinkingLevel::Minimal)
1171 );
1172 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1173 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1174 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1175 assert_eq!(
1176 parse_thinking_level("Standard"),
1177 Some(ThinkingLevel::Medium)
1178 );
1179 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1180 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1181 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1182 assert_eq!(parse_thinking_level("invalid"), None);
1183 }
1184
1185 #[test]
1186 fn test_parse_boolish() {
1187 assert!(parse_boolish("true").unwrap());
1188 assert!(parse_boolish("1").unwrap());
1189 assert!(parse_boolish("yes").unwrap());
1190 assert!(parse_boolish("ON").unwrap());
1191 assert!(!parse_boolish("false").unwrap());
1192 assert!(!parse_boolish("0").unwrap());
1193 assert!(!parse_boolish("no").unwrap());
1194 assert!(!parse_boolish("OFF").unwrap());
1195 assert!(parse_boolish("maybe").is_err());
1196 }
1197
1198 #[test]
1201 fn test_effective_model_combines_provider_and_model() {
1202 let mut settings = Settings::default();
1203 settings.default_provider = Some("openai".to_string());
1204 settings.default_model = Some("gpt-4o".to_string());
1205 assert_eq!(
1206 settings.effective_model(None),
1207 Some("openai/gpt-4o".to_string())
1208 );
1209 }
1210
1211 #[test]
1212 fn test_effective_model_cli_overrides() {
1213 let mut settings = Settings::default();
1214 settings.default_provider = Some("openai".to_string());
1215 settings.default_model = Some("gpt-4o".to_string());
1216 assert_eq!(
1217 settings.effective_model(Some("anthropic/claude-3")),
1218 Some("anthropic/claude-3".to_string())
1219 );
1220 }
1221
1222 #[test]
1223 fn test_effective_model_no_provider_returns_bare() {
1224 let mut settings = Settings::default();
1225 settings.default_model = Some("gpt-4o".to_string());
1226 assert_eq!(settings.effective_model(None), Some("gpt-4o".to_string()));
1227 }
1228
1229 #[test]
1230 fn test_effective_model_falls_back_to_last_used() {
1231 let mut settings = Settings::default();
1232 settings.last_used_model = Some("anthropic/claude-3".to_string());
1233 assert_eq!(
1234 settings.effective_model(None),
1235 Some("anthropic/claude-3".to_string())
1236 );
1237 }
1238
1239 #[test]
1240 fn test_effective_temperature_prefers_f64() {
1241 let mut settings = Settings::default();
1242 settings.temperature = Some(0.5);
1243 settings.default_temperature = Some(0.7);
1244 assert_eq!(settings.effective_temperature(), Some(0.7));
1245 }
1246
1247 #[test]
1248 fn test_effective_temperature_falls_back_to_f32() {
1249 let mut settings = Settings::default();
1250 settings.temperature = Some(0.5);
1251 assert_eq!(settings.effective_temperature(), Some(0.5));
1252 }
1253
1254 #[test]
1255 fn test_effective_max_tokens_prefers_usize() {
1256 let mut settings = Settings::default();
1257 settings.max_tokens = Some(1024);
1258 settings.max_response_tokens = Some(4096);
1259 assert_eq!(settings.effective_max_tokens(), Some(4096));
1260 }
1261
1262 #[test]
1263 fn test_effective_max_tokens_falls_back_to_u32() {
1264 let mut settings = Settings::default();
1265 settings.max_tokens = Some(1024);
1266 assert_eq!(settings.effective_max_tokens(), Some(1024));
1267 }
1268
1269 #[test]
1272 fn test_effective_session_dir_default() {
1273 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1274 let settings = Settings::default();
1275 let dir = settings.effective_session_dir().unwrap();
1276 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1277 }
1278
1279 #[test]
1280 fn test_effective_session_dir_from_field() {
1281 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1282 let mut settings = Settings::default();
1283 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1284 assert_eq!(
1285 settings.effective_session_dir().unwrap(),
1286 PathBuf::from("/tmp/oxi-sessions")
1287 );
1288 }
1289
1290 #[test]
1291 fn test_effective_session_dir_env_disabled() {
1292 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1295 env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
1296 let settings = Settings::default();
1297 let dir = settings.effective_session_dir().unwrap();
1299 assert!(
1300 dir.ends_with("sessions"),
1301 "expected default sessions dir, got: {:?}",
1302 dir
1303 );
1304 }
1305
1306 #[test]
1309 fn test_migration_v0_to_v1() {
1310 let mut settings = Settings::default();
1311 settings.version = 0;
1312 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1315 assert_eq!(migrated.version, SETTINGS_VERSION);
1316 assert_eq!(migrated.tool_timeout_seconds, 120);
1317 }
1318
1319 #[test]
1320 fn test_migration_already_current() {
1321 let settings = Settings::default();
1322 let migrated = Settings::migrate(settings).unwrap();
1323 assert_eq!(migrated.version, SETTINGS_VERSION);
1324 }
1325
1326 #[test]
1327 fn test_migration_v3_to_v4_splits_model() {
1328 let mut settings = Settings::default();
1329 settings.version = 3;
1330 settings.default_model = Some("openai/gpt-4o".to_string());
1331 settings.default_provider = None;
1332
1333 let migrated = Settings::migrate(settings).unwrap();
1334 assert_eq!(migrated.version, SETTINGS_VERSION);
1335 assert_eq!(migrated.default_model, Some("gpt-4o".to_string()));
1336 assert_eq!(migrated.default_provider, Some("openai".to_string()));
1337 }
1338
1339 #[test]
1340 fn test_migration_v3_no_slash_keeps_model() {
1341 let mut settings = Settings::default();
1342 settings.version = 3;
1343 settings.default_model = Some("bare-model-name".to_string());
1344
1345 let migrated = Settings::migrate(settings).unwrap();
1346 assert_eq!(migrated.version, SETTINGS_VERSION);
1347 assert_eq!(migrated.default_model, Some("bare-model-name".to_string()));
1348 }
1349
1350 #[test]
1351 fn test_migration_future_version_fails() {
1352 let mut settings = Settings::default();
1353 settings.version = 9999;
1354 assert!(Settings::migrate(settings).is_err());
1355 }
1356
1357 #[test]
1360 fn test_save_and_load_roundtrip() {
1361 let tmp = tempfile::tempdir().unwrap();
1362 let settings_path = tmp.path().join("settings.toml");
1363
1364 let mut original = Settings::default();
1365 original.default_model = Some("gpt-4o".to_string());
1366 original.default_provider = Some("openai".to_string());
1367 original.theme = "dracula".to_string();
1368 original.tool_timeout_seconds = 60;
1369
1370 let content = toml::to_string_pretty(&original).unwrap();
1372 fs::write(&settings_path, &content).unwrap();
1373
1374 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1376 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1377
1378 assert_eq!(loaded.default_model, original.default_model);
1379 assert_eq!(loaded.theme, original.theme);
1380 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1381 }
1382
1383 #[test]
1384 fn test_toml_roundtrip_preserves_new_fields() {
1385 let mut settings = Settings::default();
1386 settings.default_temperature = Some(0.8);
1387 settings.max_response_tokens = Some(8192);
1388 settings.auto_compaction = false;
1389 settings.extensions_enabled = false;
1390 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1391
1392 let toml_str = toml::to_string_pretty(&settings).unwrap();
1393 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1394
1395 assert_eq!(parsed.default_temperature, Some(0.8));
1396 assert_eq!(parsed.max_response_tokens, Some(8192));
1397 assert!(!parsed.auto_compaction);
1398 assert!(!parsed.extensions_enabled);
1399 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1400 }
1401
1402 #[test]
1405 fn test_json_roundtrip() {
1406 let mut settings = Settings::default();
1407 settings.default_model = Some("gpt-4o".to_string());
1408 settings.default_provider = Some("openai".to_string());
1409 settings.theme = "dracula".to_string();
1410 settings.tool_timeout_seconds = 60;
1411 settings.default_temperature = Some(0.8);
1412 settings.max_response_tokens = Some(8192);
1413
1414 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1415 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1416
1417 assert_eq!(parsed.default_model, settings.default_model);
1418 assert_eq!(parsed.theme, settings.theme);
1419 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1420 assert_eq!(parsed.default_temperature, settings.default_temperature);
1421 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1422 }
1423
1424 #[test]
1425 fn test_json_serialize_for_format() {
1426 let mut settings = Settings::default();
1427 settings.default_model = Some("claude-3".to_string());
1428 settings.default_provider = Some("anthropic".to_string());
1429 settings.thinking_level = ThinkingLevel::Minimal;
1430
1431 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1432 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1433
1434 assert_eq!(parsed.default_model, Some("claude-3".to_string()));
1435 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1436 }
1437
1438 #[test]
1439 fn test_toml_serialize_for_format() {
1440 let mut settings = Settings::default();
1441 settings.default_model = Some("gemini-pro".to_string());
1442 settings.default_provider = Some("google".to_string());
1443 settings.thinking_level = ThinkingLevel::High;
1444
1445 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1446 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1447
1448 assert_eq!(parsed.default_model, Some("gemini-pro".to_string()));
1449 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1450 }
1451
1452 #[test]
1453 fn test_parse_from_str_json() {
1454 let json_content = r#"{
1455 "default_model": "gpt-4",
1456 "default_provider": "openai",
1457 "theme": "nord",
1458 "tool_timeout_seconds": 90
1459 }"#;
1460
1461 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1462 assert_eq!(settings.default_model, Some("gpt-4".to_string()));
1463 assert_eq!(settings.default_provider, Some("openai".to_string()));
1464 assert_eq!(settings.theme, "nord");
1465 assert_eq!(settings.tool_timeout_seconds, 90);
1466 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1468 assert!(settings.extensions_enabled);
1469 }
1470
1471 #[test]
1472 fn test_parse_from_str_toml() {
1473 let toml_content = r#"
1474default_model = "claude-opus"
1475default_provider = "anthropic"
1476theme = "monokai"
1477tool_timeout_seconds = 45
1478"#;
1479
1480 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1481 assert_eq!(settings.default_model, Some("claude-opus".to_string()));
1482 assert_eq!(settings.default_provider, Some("anthropic".to_string()));
1483 assert_eq!(settings.theme, "monokai");
1484 assert_eq!(settings.tool_timeout_seconds, 45);
1485 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1486 }
1487
1488 #[test]
1489 fn test_layer_file_json() {
1490 let base = Settings::default();
1491
1492 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1493 let json_content = r#"{
1494 "default_model": "gpt-4o",
1495 "default_provider": "openai",
1496 "theme": "dracula",
1497 "auto_compaction": false
1498 }"#;
1499 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1500
1501 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1502 assert_eq!(merged.default_model, Some("gpt-4o".to_string()));
1503 assert_eq!(merged.default_provider, Some("openai".to_string()));
1504 assert_eq!(merged.theme, "dracula");
1505 assert!(!merged.auto_compaction);
1506 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1508 assert!(merged.extensions_enabled);
1509 assert_eq!(merged.tool_timeout_seconds, 120);
1510 }
1511
1512 #[test]
1513 fn test_layer_file_json_preserves_unset() {
1514 let mut base = Settings::default();
1515 base.default_provider = Some("deepseek".to_string());
1516
1517 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1518 let json_content = r#"{ "theme": "nord" }"#;
1519 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1520
1521 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1522 assert_eq!(merged.theme, "nord");
1523 assert_eq!(merged.default_provider, Some("deepseek".to_string()));
1524 }
1525
1526 #[test]
1527 fn test_save_to_json() {
1528 let tmp = tempfile::tempdir().unwrap();
1529 let settings_path = tmp.path().join("settings.json");
1530
1531 let mut settings = Settings::default();
1532 settings.default_model = Some("gpt-4o".to_string());
1533 settings.default_provider = Some("openai".to_string());
1534 settings.theme = "dracula".to_string();
1535 settings.tool_timeout_seconds = 60;
1536
1537 settings.save_to(&settings_path).unwrap();
1538
1539 let content = fs::read_to_string(&settings_path).unwrap();
1541 let parsed: Settings = serde_json::from_str(&content).unwrap();
1542 assert_eq!(parsed.default_model, Some("gpt-4o".to_string()));
1543 assert_eq!(parsed.theme, "dracula");
1544 assert_eq!(parsed.tool_timeout_seconds, 60);
1545 }
1546
1547 #[test]
1548 fn test_save_to_toml() {
1549 let tmp = tempfile::tempdir().unwrap();
1550 let settings_path = tmp.path().join("settings.toml");
1551
1552 let mut settings = Settings::default();
1553 settings.default_model = Some("gemini-pro".to_string());
1554 settings.default_provider = Some("google".to_string());
1555 settings.theme = "monokai".to_string();
1556 settings.tool_timeout_seconds = 90;
1557
1558 settings.save_to(&settings_path).unwrap();
1559
1560 let content = fs::read_to_string(&settings_path).unwrap();
1562 let parsed: Settings = toml::from_str(&content).unwrap();
1563 assert_eq!(parsed.default_model, Some("gemini-pro".to_string()));
1564 assert_eq!(parsed.theme, "monokai");
1565 assert_eq!(parsed.tool_timeout_seconds, 90);
1566 }
1567
1568 #[test]
1569 fn test_load_from_dir_with_json_project_config() {
1570 let _guard = EnvGuard::new(&[
1571 "OXI_MODEL",
1572 "OXI_PROVIDER",
1573 "OXI_THEME",
1574 "OXI_TOOL_TIMEOUT",
1575 "OXI_TEMPERATURE",
1576 "OXI_MAX_TOKENS",
1577 "OXI_SESSION_DIR",
1578 "OXI_STREAM",
1579 "OXI_EXTENSIONS_ENABLED",
1580 ]);
1581 let tmp = tempfile::tempdir().unwrap();
1582 let oxi_dir = tmp.path().join(".oxi");
1583 fs::create_dir_all(&oxi_dir).unwrap();
1584 let settings_path = oxi_dir.join("settings.json");
1585 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1587 fs::write(&settings_path, json_content).unwrap();
1588
1589 let settings = Settings::load_from(tmp.path()).unwrap();
1590 assert_eq!(settings.default_model, Some("gemini-2.0-flash".to_string()));
1592 assert_eq!(settings.default_provider, Some("google".to_string()));
1593 }
1594
1595 #[test]
1596 fn test_find_project_settings_json_priority() {
1597 let tmp = tempfile::tempdir().unwrap();
1598 let oxi_dir = tmp.path().join(".oxi");
1599 fs::create_dir_all(&oxi_dir).unwrap();
1600
1601 let json_path = oxi_dir.join("settings.json");
1603 let toml_path = oxi_dir.join("settings.toml");
1604 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1605 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1606
1607 let found = Settings::find_project_settings(tmp.path());
1609 assert!(found.is_some());
1610 assert_eq!(
1611 found.unwrap().file_name().unwrap().to_str().unwrap(),
1612 "settings.json"
1613 );
1614 }
1615
1616 #[test]
1617 fn test_find_project_settings_json_only() {
1618 let tmp = tempfile::tempdir().unwrap();
1619 let oxi_dir = tmp.path().join(".oxi");
1620 fs::create_dir_all(&oxi_dir).unwrap();
1621
1622 let json_path = oxi_dir.join("settings.json");
1623 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1624
1625 let found = Settings::find_project_settings(tmp.path());
1626 assert!(found.is_some());
1627 assert_eq!(
1628 found.unwrap().file_name().unwrap().to_str().unwrap(),
1629 "settings.json"
1630 );
1631 }
1632
1633 #[test]
1634 fn test_find_project_settings_toml_fallback() {
1635 let tmp = tempfile::tempdir().unwrap();
1636 let oxi_dir = tmp.path().join(".oxi");
1637 fs::create_dir_all(&oxi_dir).unwrap();
1638
1639 let toml_path = oxi_dir.join("settings.toml");
1640 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1641
1642 let found = Settings::find_project_settings(tmp.path());
1643 assert!(found.is_some());
1644 assert_eq!(
1645 found.unwrap().file_name().unwrap().to_str().unwrap(),
1646 "settings.toml"
1647 );
1648 }
1649
1650 #[test]
1651 fn test_detect_format() {
1652 let json_path = PathBuf::from("/test/settings.json");
1653 let toml_path = PathBuf::from("/test/settings.toml");
1654 let unknown_path = PathBuf::from("/test/settings");
1655
1656 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1657 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1658 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1659 }
1661
1662 #[test]
1663 fn test_settings_format_extension() {
1664 assert_eq!(SettingsFormat::Json.extension(), "json");
1665 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1666 }
1667
1668 #[test]
1669 fn test_layer_json_over_toml() {
1670 let tmp = tempfile::tempdir().unwrap();
1672 let oxi_dir = tmp.path().join(".oxi");
1673 fs::create_dir_all(&oxi_dir).unwrap();
1674
1675 let json_path = oxi_dir.join("settings.json");
1676 let toml_path = oxi_dir.join("settings.toml");
1677
1678 fs::write(&json_path, r#"{ "default_model": "json-model" }"#).unwrap();
1680 fs::write(&toml_path, r#"default_model = "toml-model""#).unwrap();
1682
1683 let settings = Settings::load_from(tmp.path()).unwrap();
1685 assert_eq!(settings.default_model, Some("json-model".to_string()));
1686 }
1687
1688 #[test]
1689 fn test_mixed_format_loading() {
1690 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1692 let toml_content = r#"
1693default_model = "loaded-via-toml"
1694theme = "loaded-theme"
1695stream_responses = false
1696"#;
1697 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1698
1699 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1700 assert_eq!(merged.default_model, Some("loaded-via-toml".to_string()));
1701 assert_eq!(merged.theme, "loaded-theme");
1702 assert!(!merged.stream_responses);
1703 }
1704
1705 #[test]
1706 fn test_merge_json_values() {
1707 let base = serde_json::json!({
1708 "version": 1,
1709 "theme": "default",
1710 "extensions": ["ext1"],
1711 "nested": {
1712 "a": 1,
1713 "b": 2
1714 }
1715 });
1716
1717 let override_ = serde_json::json!({
1718 "version": 2,
1719 "theme": "dark",
1720 "extensions": ["ext2"],
1721 "nested": {
1722 "b": 20,
1723 "c": 30
1724 }
1725 });
1726
1727 let merged = merge_json_values(base, override_);
1728
1729 assert_eq!(merged["version"], 2);
1730 assert_eq!(merged["theme"], "dark");
1731 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1733 assert_eq!(merged["nested"]["a"], 1);
1735 assert_eq!(merged["nested"]["b"], 20);
1736 assert_eq!(merged["nested"]["c"], 30);
1737 }
1738
1739 #[test]
1740 fn test_save_project_preserves_existing_format() {
1741 let tmp = tempfile::tempdir().unwrap();
1742 let oxi_dir = tmp.path().join(".oxi");
1743 fs::create_dir_all(&oxi_dir).unwrap();
1744
1745 let toml_path = oxi_dir.join("settings.toml");
1747 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1748
1749 let mut settings = Settings::default();
1750 settings.theme = "new-theme".to_string();
1751 settings.save_project(tmp.path()).unwrap();
1752
1753 let content = fs::read_to_string(&toml_path).unwrap();
1755 assert!(content.contains("new-theme"));
1756 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1757 }
1758
1759 #[test]
1760 fn test_save_project_creates_json_by_default() {
1761 let tmp = tempfile::tempdir().unwrap();
1762 let oxi_dir = tmp.path().join(".oxi");
1763 fs::create_dir_all(&oxi_dir).unwrap();
1764 let mut settings = Settings::default();
1767 settings.theme = "json-theme".to_string();
1768 settings.save_project(tmp.path()).unwrap();
1769
1770 let json_path = oxi_dir.join("settings.json");
1772 assert!(json_path.exists());
1773 let content = fs::read_to_string(&json_path).unwrap();
1774 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1775 assert!(content.contains("json-theme"));
1776 }
1777
1778 #[test]
1781 fn test_custom_provider_default_api() {
1782 use super::CustomProvider;
1783 let cp = CustomProvider {
1784 name: "test".to_string(),
1785 base_url: "https://api.test.com/v1".to_string(),
1786 api_key_env: "TEST_API_KEY".to_string(),
1787 api: super::default_custom_provider_api(),
1788 };
1789 assert_eq!(cp.api, "openai-completions");
1790 }
1791
1792 #[test]
1793 fn test_custom_provider_toml_deserialize() {
1794 let toml_content = r#"
1795[[custom_providers]]
1796name = "minimax"
1797base_url = "https://api.minimax.chat/v1"
1798api_key_env = "MINIMAX_API_KEY"
1799api = "openai-completions"
1800
1801[[custom_providers]]
1802name = "zai"
1803base_url = "https://api.z.ai/v1"
1804api_key_env = "ZAI_API_KEY"
1805api = "openai-responses"
1806"#;
1807 let settings: Settings = toml::from_str(toml_content).unwrap();
1808 assert_eq!(settings.custom_providers.len(), 2);
1809 assert_eq!(settings.custom_providers[0].name, "minimax");
1810 assert_eq!(
1811 settings.custom_providers[0].base_url,
1812 "https://api.minimax.chat/v1"
1813 );
1814 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
1815 assert_eq!(settings.custom_providers[0].api, "openai-completions");
1816 assert_eq!(settings.custom_providers[1].name, "zai");
1817 assert_eq!(settings.custom_providers[1].api, "openai-responses");
1818 }
1819
1820 #[test]
1821 fn test_custom_provider_json_deserialize() {
1822 let json_content = r#"{
1823 "custom_providers": [
1824 {
1825 "name": "minimax",
1826 "base_url": "https://api.minimax.chat/v1",
1827 "api_key_env": "MINIMAX_API_KEY",
1828 "api": "openai-completions"
1829 }
1830 ]
1831 }"#;
1832 let settings: Settings = serde_json::from_str(json_content).unwrap();
1833 assert_eq!(settings.custom_providers.len(), 1);
1834 assert_eq!(settings.custom_providers[0].name, "minimax");
1835 }
1836
1837 #[test]
1838 fn test_custom_provider_toml_roundtrip() {
1839 let mut settings = Settings::default();
1840 settings.custom_providers.push(super::CustomProvider {
1841 name: "test".to_string(),
1842 base_url: "https://api.test.com/v1".to_string(),
1843 api_key_env: "TEST_API_KEY".to_string(),
1844 api: "openai-completions".to_string(),
1845 });
1846
1847 let toml_str = toml::to_string_pretty(&settings).unwrap();
1848 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1849 assert_eq!(parsed.custom_providers.len(), 1);
1850 assert_eq!(parsed.custom_providers[0].name, "test");
1851 assert_eq!(
1852 parsed.custom_providers[0].base_url,
1853 "https://api.test.com/v1"
1854 );
1855 }
1856
1857 #[test]
1858 fn test_custom_provider_defaults_empty() {
1859 let settings = Settings::default();
1860 assert!(settings.custom_providers.is_empty());
1861 }
1862
1863 #[test]
1864 fn test_custom_provider_layer_file() {
1865 let base = Settings::default();
1866
1867 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1868 let toml_content = r#"
1869[[custom_providers]]
1870name = "my-provider"
1871base_url = "https://api.my-provider.com/v1"
1872api_key_env = "MY_PROVIDER_API_KEY"
1873"#;
1874 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1875
1876 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1877 assert_eq!(merged.custom_providers.len(), 1);
1878 assert_eq!(merged.custom_providers[0].name, "my-provider");
1879 assert_eq!(merged.custom_providers[0].api, "openai-completions");
1881 }
1882}