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
204fn default_theme() -> String {
205 "default".to_string()
206}
207
208fn default_thinking_level() -> ThinkingLevel {
209 ThinkingLevel::Medium
210}
211
212fn default_session_history_size() -> usize {
213 100
214}
215
216fn default_true() -> bool {
217 true
218}
219
220fn default_false() -> bool {
221 false
222}
223
224fn default_circuit_failure_threshold() -> u32 {
225 5
226}
227
228fn default_circuit_open_duration_secs() -> u64 {
229 30
230}
231
232fn default_tool_timeout() -> u64 {
233 120
234}
235
236impl Default for Settings {
237 fn default() -> Self {
238 Self {
239 version: SETTINGS_VERSION,
240 thinking_level: ThinkingLevel::Medium,
241 theme: default_theme(),
242 default_model: None,
243 default_provider: None,
244 last_used_model: None,
245 last_used_provider: None,
246 max_tokens: None,
247 temperature: None,
248 default_temperature: None,
249 max_response_tokens: None,
250 session_history_size: default_session_history_size(),
251 session_dir: None,
252 stream_responses: true,
253 extensions_enabled: true,
254 auto_compaction: true,
255 disabled_tools: Vec::new(),
256 tool_timeout_seconds: default_tool_timeout(),
257 extensions: Vec::new(),
258 skills: Vec::new(),
259 prompts: Vec::new(),
260 themes: Vec::new(),
261 custom_providers: Vec::new(),
262 dynamic_models: HashMap::new(),
263 enable_routing: false,
265 router_profile: None,
266 prefer_cost_efficient: true,
267 fallback_chain: Vec::new(),
268 enable_fallback: true,
269 disable_fallback: false,
270 circuit_breaker_failure_threshold: 5,
271 circuit_breaker_open_duration_secs: 30,
272 }
273 }
274}
275
276impl Settings {
277 pub fn settings_dir() -> Result<PathBuf> {
281 let base = dirs::home_dir().context("Cannot determine home directory")?;
282 Ok(base.join(".oxi"))
283 }
284
285 pub fn settings_toml_path() -> Result<PathBuf> {
287 Ok(Self::settings_dir()?.join("settings.toml"))
288 }
289
290 pub fn settings_json_path() -> Result<PathBuf> {
292 Ok(Self::settings_dir()?.join("settings.json"))
293 }
294
295 pub fn settings_path() -> Result<PathBuf> {
302 let json_path = Self::settings_json_path()?;
303 let toml_path = Self::settings_toml_path()?;
304
305 if json_path.exists() && toml_path.exists() {
306 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
308 return Ok(json_path);
309 }
310
311 if json_path.exists() {
312 return Ok(json_path);
313 }
314
315 if toml_path.exists() {
316 return Ok(toml_path);
317 }
318
319 Ok(json_path)
321 }
322
323 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
328 let json_path = Self::settings_json_path()?;
329 let toml_path = Self::settings_toml_path()?;
330
331 let (primary, secondary) = if prefer_json {
332 (&json_path, &toml_path)
333 } else {
334 (&toml_path, &json_path)
335 };
336
337 if primary.exists() {
338 return Ok(primary.clone());
339 }
340
341 if secondary.exists() {
342 return Ok(secondary.clone());
343 }
344
345 Ok(primary.clone())
347 }
348
349 pub fn detect_format(path: &Path) -> SettingsFormat {
351 match path.extension().and_then(|e| e.to_str()) {
352 Some("json") => SettingsFormat::Json,
353 Some("toml") => SettingsFormat::Toml,
354 _ => SettingsFormat::Json, }
356 }
357
358 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
363 let mut dir = start_dir.to_path_buf();
364 loop {
365 let json_candidate = dir.join(".oxi").join("settings.json");
367 if json_candidate.exists() {
368 return Some(json_candidate);
369 }
370
371 let toml_candidate = dir.join(".oxi").join("settings.toml");
372 if toml_candidate.exists() {
373 return Some(toml_candidate);
374 }
375
376 if !dir.pop() {
377 return None;
378 }
379 }
380 }
381
382 pub fn effective_session_dir(&self) -> Result<PathBuf> {
386 if let Some(ref dir) = self.session_dir {
387 return Ok(dir.clone());
388 }
389 Ok(Self::settings_dir()?.join("sessions"))
390 }
391
392 pub fn load() -> Result<Self> {
410 Self::load_from_cwd()
411 }
412
413 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
415 let mut settings = Settings::default();
417
418 if let Ok(global_path) = Self::settings_path() {
420 if global_path.exists() {
421 settings = Self::layer_file(&settings, &global_path)?;
422 }
423 }
424
425 if let Some(project_path) = Self::find_project_settings(dir) {
427 settings = Self::layer_file(&settings, &project_path)?;
428 }
429
430 settings.apply_env();
432
433 settings = Self::migrate(settings)?;
435
436 Ok(settings)
437 }
438
439 pub fn load_from_cwd() -> Result<Self> {
441 let cwd = env::current_dir().context("Cannot determine current directory")?;
442 Self::load_from(&cwd)
443 }
444
445 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
451 let content = fs::read_to_string(path)
452 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
453
454 let format = Self::detect_format(path);
455 let overlay: serde_json::Value = match format {
456 SettingsFormat::Toml => {
457 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
458 format!("Failed to parse TOML settings from {}", path.display())
459 })?;
460 toml_value_to_json(toml_value)
462 }
463 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
464 format!("Failed to parse JSON settings from {}", path.display())
465 })?,
466 };
467
468 let base_json =
472 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
473
474 let merged = merge_json_values(base_json, overlay);
475 let result: Settings =
476 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
477
478 Ok(result)
479 }
480
481 #[allow(dead_code)]
507 pub fn apply_env(&mut self) {
508 }
512
513 #[allow(dead_code)]
519 pub fn from_env() -> Self {
520 Self::default()
521 }
522
523 pub fn save(&self) -> Result<()> {
530 let dir = Self::settings_dir()?;
531 let path = Self::settings_path()?;
532
533 if !dir.exists() {
534 fs::create_dir_all(&dir).with_context(|| {
535 format!("Failed to create settings directory {}", dir.display())
536 })?;
537 }
538
539 let format = Self::detect_format(&path);
540 let content = Self::serialize_for_format(self, format)?;
541
542 let tmp_path = path.with_extension("tmp");
544 fs::write(&tmp_path, &content)
545 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
546 fs::rename(&tmp_path, &path)
547 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
548
549 Ok(())
550 }
551
552 pub fn save_to(&self, path: &Path) -> Result<()> {
554 if let Some(parent) = path.parent() {
555 if !parent.exists() {
556 fs::create_dir_all(parent)
557 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
558 }
559 }
560
561 let format = Self::detect_format(path);
562 let content = Self::serialize_for_format(self, format)?;
563
564 let tmp_path = path.with_extension("tmp");
566 fs::write(&tmp_path, &content)
567 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
568 fs::rename(&tmp_path, path)
569 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
570
571 Ok(())
572 }
573
574 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
578 let dir = project_dir.join(".oxi");
579
580 if !dir.exists() {
581 fs::create_dir_all(&dir).with_context(|| {
582 format!(
583 "Failed to create project settings directory {}",
584 dir.display()
585 )
586 })?;
587 }
588
589 let json_path = dir.join("settings.json");
591 let toml_path = dir.join("settings.toml");
592
593 let path = if json_path.exists() {
594 &json_path
595 } else if toml_path.exists() {
596 &toml_path
597 } else {
598 &json_path
600 };
601
602 let format = Self::detect_format(path);
603 let content = Self::serialize_for_format(self, format)?;
604
605 let tmp_path = path.with_extension("tmp");
607 fs::write(&tmp_path, &content)
608 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
609 fs::rename(&tmp_path, path)
610 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
611
612 Ok(())
613 }
614
615 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
617 match format {
618 SettingsFormat::Toml => {
619 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
620 }
621 SettingsFormat::Json => serde_json::to_string_pretty(settings)
622 .context("Failed to serialize settings to JSON"),
623 }
624 }
625
626 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
628 match format {
629 SettingsFormat::Toml => {
630 toml::from_str(content).context("Failed to parse TOML settings")
631 }
632 SettingsFormat::Json => {
633 serde_json::from_str(content).context("Failed to parse JSON settings")
634 }
635 }
636 }
637
638 pub fn merge_cli(
651 &mut self,
652 model: Option<String>,
653 provider: Option<String>,
654 enable_routing: Option<bool>,
655 prefer_cost_efficient: Option<bool>,
656 fallback_chain: Option<Vec<String>>,
657 disable_fallback: Option<bool>,
658 ) {
659 if let Some(m) = model {
660 self.default_model = Some(m);
661 }
662 if let Some(p) = provider {
663 self.default_provider = Some(p);
664 }
665 if let Some(r) = enable_routing {
666 self.enable_routing = r;
667 }
668 if let Some(p) = prefer_cost_efficient {
669 self.prefer_cost_efficient = p;
670 }
671 if let Some(fc) = fallback_chain {
672 if !fc.is_empty() {
673 self.fallback_chain = fc;
674 }
675 }
676 if let Some(df) = disable_fallback {
677 self.disable_fallback = df;
678 if df {
680 self.enable_fallback = false;
681 }
682 }
683 }
684
685 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
689 cli_model
690 .map(String::from)
691 .or_else(|| {
692 if let (Some(provider), Some(model)) = (&self.default_provider, &self.default_model)
694 {
695 Some(format!("{}/{}", provider, model))
696 } else {
697 self.default_model.clone()
698 }
699 })
700 .or_else(|| self.last_used_model.clone())
701 }
702
703 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
706 cli_provider
707 .map(String::from)
708 .or_else(|| self.default_provider.clone())
709 .or_else(|| self.last_used_provider.clone())
710 }
711
712 pub fn effective_temperature(&self) -> Option<f64> {
715 self.default_temperature
716 .or(self.temperature.map(|t| t as f64))
717 }
718
719 pub fn effective_max_tokens(&self) -> Option<usize> {
722 self.max_response_tokens
723 .or(self.max_tokens.map(|t| t as usize))
724 }
725
726 pub fn router_profile(&self) -> Option<&str> {
728 self.router_profile.as_deref()
729 }
730
731 pub fn save_last_used(model_id: &str) {
735 if let Ok(mut settings) = Self::load() {
736 let parts: Vec<&str> = model_id.splitn(2, '/').collect();
737 settings.last_used_model = Some(model_id.to_string());
738 settings.last_used_provider = parts.first().map(|s| s.to_string());
739 let _ = settings.save();
740 }
741 }
742
743 pub fn save_theme(&mut self, name: &str) -> Result<()> {
745 self.theme = name.to_string();
746 self.save()
747 }
748
749 pub fn get_theme_name(&self) -> String {
751 if self.theme.is_empty() || self.theme == "default" {
752 "oxi_dark".to_string()
753 } else {
754 self.theme.clone()
755 }
756 }
757
758 fn migrate(settings: Settings) -> Result<Settings> {
766 let mut settings = settings;
767
768 match settings.version {
769 SETTINGS_VERSION => {
770 }
772 0 => {
773 if settings.tool_timeout_seconds == 0 {
776 settings.tool_timeout_seconds = default_tool_timeout();
777 }
778 settings.version = SETTINGS_VERSION;
779
780 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
781 }
782 1 | 2 => {
783 settings.version = SETTINGS_VERSION;
785 tracing::info!(
786 "Migrated settings from version {} to {}",
787 settings.version,
788 SETTINGS_VERSION
789 );
790 }
791 3 => {
792 if let Some(model) = settings.default_model.take() {
794 if let Some((provider, model_name)) = model.split_once('/') {
795 settings.default_provider = Some(provider.to_string());
796 settings.default_model = Some(model_name.to_string());
797 } else {
798 settings.default_model = Some(model);
800 }
801 }
802 settings.version = SETTINGS_VERSION;
803 tracing::info!(
804 "Migrated settings from version 3 to {} (split default_model into provider + model)",
805 SETTINGS_VERSION
806 );
807 }
808 v if v > SETTINGS_VERSION => {
809 anyhow::bail!(
811 "Settings version {} is newer than supported version {}. \
812 Please update oxi.",
813 v,
814 SETTINGS_VERSION
815 );
816 }
817 v => {
818 tracing::warn!(
820 "Unknown settings version {}, attempting migration to {}",
821 v,
822 SETTINGS_VERSION
823 );
824 settings.version = SETTINGS_VERSION;
825 }
826 }
827
828 Ok(settings)
829 }
830}
831
832#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
836pub enum SettingsFormat {
837 #[default]
839 Json,
840 Toml,
842}
843
844impl SettingsFormat {
845 pub fn extension(&self) -> &'static str {
847 match self {
848 SettingsFormat::Json => "json",
849 SettingsFormat::Toml => "toml",
850 }
851 }
852}
853
854fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
858 match toml {
859 toml::Value::String(s) => serde_json::Value::String(s),
860 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
861 toml::Value::Float(f) => serde_json::Number::from_f64(f)
862 .map(serde_json::Value::Number)
863 .unwrap_or(serde_json::Value::Null),
864 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
865 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
866 toml::Value::Array(arr) => {
867 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
868 }
869 toml::Value::Table(table) => {
870 let obj = table
871 .into_iter()
872 .map(|(k, v)| (k, toml_value_to_json(v)))
873 .collect();
874 serde_json::Value::Object(obj)
875 }
876 }
877}
878
879fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
881 match (base, override_) {
882 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
884 let mut result = base_map;
885 for (key, override_value) in override_map {
886 let base_value = result.remove(&key);
887 let merged = match base_value {
888 Some(base_v) => merge_json_values(base_v, override_value),
889 None => override_value,
890 };
891 result.insert(key, merged);
892 }
893 serde_json::Value::Object(result)
894 }
895 (_, override_) => override_,
897 }
898}
899
900pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
902 match s.to_lowercase().as_str() {
903 "off" | "none" => Some(ThinkingLevel::Off),
904 "minimal" => Some(ThinkingLevel::Minimal),
905 "low" => Some(ThinkingLevel::Low),
906 "medium" | "standard" => Some(ThinkingLevel::Medium),
907 "high" | "thorough" => Some(ThinkingLevel::High),
908 "xhigh" => Some(ThinkingLevel::XHigh),
909 _ => None,
910 }
911}
912
913#[allow(dead_code)]
915fn parse_boolish(s: &str) -> Result<bool> {
916 match s.to_lowercase().as_str() {
917 "true" | "1" | "yes" | "on" => Ok(true),
918 "false" | "0" | "no" | "off" => Ok(false),
919 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use std::io::Write as IoWrite;
927 use std::sync::Mutex;
928
929 static ENV_LOCK: Mutex<()> = Mutex::new(());
931
932 struct EnvGuard {
935 saved: Vec<(String, Option<String>)>,
936 }
937
938 impl EnvGuard {
939 fn new(vars: &[&str]) -> Self {
940 let saved = vars
941 .iter()
942 .map(|&name| {
943 let old = env::var(name).ok();
944 env::remove_var(name);
945 (name.to_string(), old)
946 })
947 .collect();
948 Self { saved }
949 }
950 }
951
952 impl Drop for EnvGuard {
953 fn drop(&mut self) {
954 for (name, old) in self.saved.drain(..) {
955 match old {
956 Some(val) => env::set_var(&name, val),
957 None => env::remove_var(&name),
958 }
959 }
960 }
961 }
962
963 #[test]
966 fn test_default_settings() {
967 let settings = Settings::default();
968 assert_eq!(settings.version, SETTINGS_VERSION);
969 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
970 assert_eq!(settings.theme, "default");
971 assert!(settings.default_model.is_none());
972 assert!(settings.default_provider.is_none());
973 assert!(settings.extensions_enabled);
974 assert!(settings.auto_compaction);
975 assert_eq!(settings.tool_timeout_seconds, 120);
976 assert!(settings.stream_responses);
977 }
978
979 #[test]
980 fn test_merge_cli() {
981 let mut settings = Settings::default();
982 settings.default_model = Some("gpt-4o".to_string());
983
984 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
985 assert_eq!(settings.default_model, Some("claude".to_string()));
986
987 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
988 assert_eq!(settings.default_provider, Some("google".to_string()));
989
990 settings.merge_cli(
992 None,
993 None,
994 Some(true),
995 Some(false),
996 Some(vec!["openai/gpt-4o".to_string()]),
997 Some(false),
998 );
999 assert!(settings.enable_routing);
1000 assert!(!settings.prefer_cost_efficient);
1001 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1002 assert!(!settings.disable_fallback);
1003
1004 let mut settings2 = Settings::default();
1006 settings2.merge_cli(None, None, None, None, None, Some(true));
1007 assert!(settings2.disable_fallback);
1008 assert!(!settings2.enable_fallback);
1009 }
1010
1011 #[test]
1014 fn test_layer_file_overrides() {
1015 let base = Settings::default();
1016
1017 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1018 let toml_content = r#"
1019default_model = "openai/gpt-4o"
1020theme = "dracula"
1021"#;
1022 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1023
1024 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1025 assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
1026 assert_eq!(merged.theme, "dracula");
1027 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1029 assert!(merged.extensions_enabled);
1030 }
1031
1032 #[test]
1033 fn test_layer_file_preserves_unset() {
1034 let mut base = Settings::default();
1035 base.default_provider = Some("deepseek".to_string());
1036
1037 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1038 let toml_content = "theme = \"monokai\"\n";
1040 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1041
1042 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1043 assert_eq!(merged.theme, "monokai");
1044 assert_eq!(merged.default_provider, Some("deepseek".to_string()));
1045 }
1046
1047 #[test]
1048 fn test_load_from_dir_with_project_config() {
1049 let _guard = EnvGuard::new(&[
1050 "OXI_MODEL",
1051 "OXI_PROVIDER",
1052 "OXI_THEME",
1053 "OXI_TOOL_TIMEOUT",
1054 "OXI_TEMPERATURE",
1055 "OXI_MAX_TOKENS",
1056 "OXI_SESSION_DIR",
1057 "OXI_STREAM",
1058 "OXI_EXTENSIONS_ENABLED",
1059 ]);
1060 let tmp = tempfile::tempdir().unwrap();
1061 let oxi_dir = tmp.path().join(".oxi");
1062 fs::create_dir_all(&oxi_dir).unwrap();
1063 let settings_path = oxi_dir.join("settings.toml");
1064 fs::write(
1066 &settings_path,
1067 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1068 )
1069 .unwrap();
1070
1071 let settings = Settings::load_from(tmp.path()).unwrap();
1072 assert_eq!(settings.default_model, Some("gemini-2.0-flash".to_string()));
1074 assert_eq!(settings.default_provider, Some("google".to_string()));
1075 }
1076
1077 #[test]
1078 fn test_load_from_dir_no_config() {
1079 let _guard = EnvGuard::new(&[
1081 "OXI_MODEL",
1082 "OXI_PROVIDER",
1083 "OXI_THEME",
1084 "OXI_TOOL_TIMEOUT",
1085 "OXI_TEMPERATURE",
1086 "OXI_MAX_TOKENS",
1087 "OXI_SESSION_DIR",
1088 "OXI_STREAM",
1089 "OXI_EXTENSIONS_ENABLED",
1090 ]);
1091 let tmp = tempfile::tempdir().unwrap();
1092 let settings = Settings::load_from(tmp.path()).unwrap();
1093 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1095 }
1096
1097 #[test]
1100 fn test_from_env() {
1101 let _guard = EnvGuard::new(&[
1104 "OXI_MODEL",
1106 "OXI_THEME",
1107 "OXI_TOOL_TIMEOUT",
1108 "OXI_PROVIDER",
1109 "OXI_DEFAULT_MODEL",
1110 ]);
1111
1112 let settings = Settings::from_env();
1113 assert_eq!(settings.default_model, None);
1115 assert_eq!(settings.theme, "default");
1116 assert_eq!(settings.tool_timeout_seconds, 120);
1117 }
1118
1119 #[test]
1120 fn test_apply_env_boolish() {
1121 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1124 env::set_var("OXI_STREAM", "false");
1125 env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1126
1127 let mut settings = Settings::default();
1128 settings.apply_env();
1129 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1133
1134 #[test]
1135 fn test_apply_env_temperature() {
1136 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1138 env::set_var("OXI_TEMPERATURE", "0.7");
1139
1140 let mut settings = Settings::default();
1141 settings.apply_env();
1142 assert_eq!(settings.default_temperature, None);
1144 }
1145
1146 #[test]
1147 fn test_env_does_not_override_when_unset() {
1148 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1149 let settings = Settings::from_env();
1150 assert!(settings.default_model.is_none());
1151 assert!(settings.default_provider.is_none());
1152 }
1153
1154 #[test]
1157 fn test_parse_thinking_level() {
1158 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1159 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1160 assert_eq!(
1161 parse_thinking_level("MINIMAL"),
1162 Some(ThinkingLevel::Minimal)
1163 );
1164 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1165 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1166 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1167 assert_eq!(
1168 parse_thinking_level("Standard"),
1169 Some(ThinkingLevel::Medium)
1170 );
1171 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1172 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1173 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1174 assert_eq!(parse_thinking_level("invalid"), None);
1175 }
1176
1177 #[test]
1178 fn test_parse_boolish() {
1179 assert!(parse_boolish("true").unwrap());
1180 assert!(parse_boolish("1").unwrap());
1181 assert!(parse_boolish("yes").unwrap());
1182 assert!(parse_boolish("ON").unwrap());
1183 assert!(!parse_boolish("false").unwrap());
1184 assert!(!parse_boolish("0").unwrap());
1185 assert!(!parse_boolish("no").unwrap());
1186 assert!(!parse_boolish("OFF").unwrap());
1187 assert!(parse_boolish("maybe").is_err());
1188 }
1189
1190 #[test]
1193 fn test_effective_model_combines_provider_and_model() {
1194 let mut settings = Settings::default();
1195 settings.default_provider = Some("openai".to_string());
1196 settings.default_model = Some("gpt-4o".to_string());
1197 assert_eq!(
1198 settings.effective_model(None),
1199 Some("openai/gpt-4o".to_string())
1200 );
1201 }
1202
1203 #[test]
1204 fn test_effective_model_cli_overrides() {
1205 let mut settings = Settings::default();
1206 settings.default_provider = Some("openai".to_string());
1207 settings.default_model = Some("gpt-4o".to_string());
1208 assert_eq!(
1209 settings.effective_model(Some("anthropic/claude-3")),
1210 Some("anthropic/claude-3".to_string())
1211 );
1212 }
1213
1214 #[test]
1215 fn test_effective_model_no_provider_returns_bare() {
1216 let mut settings = Settings::default();
1217 settings.default_model = Some("gpt-4o".to_string());
1218 assert_eq!(settings.effective_model(None), Some("gpt-4o".to_string()));
1219 }
1220
1221 #[test]
1222 fn test_effective_model_falls_back_to_last_used() {
1223 let mut settings = Settings::default();
1224 settings.last_used_model = Some("anthropic/claude-3".to_string());
1225 assert_eq!(
1226 settings.effective_model(None),
1227 Some("anthropic/claude-3".to_string())
1228 );
1229 }
1230
1231 #[test]
1232 fn test_effective_temperature_prefers_f64() {
1233 let mut settings = Settings::default();
1234 settings.temperature = Some(0.5);
1235 settings.default_temperature = Some(0.7);
1236 assert_eq!(settings.effective_temperature(), Some(0.7));
1237 }
1238
1239 #[test]
1240 fn test_effective_temperature_falls_back_to_f32() {
1241 let mut settings = Settings::default();
1242 settings.temperature = Some(0.5);
1243 assert_eq!(settings.effective_temperature(), Some(0.5));
1244 }
1245
1246 #[test]
1247 fn test_effective_max_tokens_prefers_usize() {
1248 let mut settings = Settings::default();
1249 settings.max_tokens = Some(1024);
1250 settings.max_response_tokens = Some(4096);
1251 assert_eq!(settings.effective_max_tokens(), Some(4096));
1252 }
1253
1254 #[test]
1255 fn test_effective_max_tokens_falls_back_to_u32() {
1256 let mut settings = Settings::default();
1257 settings.max_tokens = Some(1024);
1258 assert_eq!(settings.effective_max_tokens(), Some(1024));
1259 }
1260
1261 #[test]
1264 fn test_effective_session_dir_default() {
1265 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1266 let settings = Settings::default();
1267 let dir = settings.effective_session_dir().unwrap();
1268 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1269 }
1270
1271 #[test]
1272 fn test_effective_session_dir_from_field() {
1273 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1274 let mut settings = Settings::default();
1275 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1276 assert_eq!(
1277 settings.effective_session_dir().unwrap(),
1278 PathBuf::from("/tmp/oxi-sessions")
1279 );
1280 }
1281
1282 #[test]
1283 fn test_effective_session_dir_env_disabled() {
1284 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1287 env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
1288 let settings = Settings::default();
1289 let dir = settings.effective_session_dir().unwrap();
1291 assert!(
1292 dir.ends_with("sessions"),
1293 "expected default sessions dir, got: {:?}",
1294 dir
1295 );
1296 }
1297
1298 #[test]
1301 fn test_migration_v0_to_v1() {
1302 let mut settings = Settings::default();
1303 settings.version = 0;
1304 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1307 assert_eq!(migrated.version, SETTINGS_VERSION);
1308 assert_eq!(migrated.tool_timeout_seconds, 120);
1309 }
1310
1311 #[test]
1312 fn test_migration_already_current() {
1313 let settings = Settings::default();
1314 let migrated = Settings::migrate(settings).unwrap();
1315 assert_eq!(migrated.version, SETTINGS_VERSION);
1316 }
1317
1318 #[test]
1319 fn test_migration_v3_to_v4_splits_model() {
1320 let mut settings = Settings::default();
1321 settings.version = 3;
1322 settings.default_model = Some("openai/gpt-4o".to_string());
1323 settings.default_provider = None;
1324
1325 let migrated = Settings::migrate(settings).unwrap();
1326 assert_eq!(migrated.version, SETTINGS_VERSION);
1327 assert_eq!(migrated.default_model, Some("gpt-4o".to_string()));
1328 assert_eq!(migrated.default_provider, Some("openai".to_string()));
1329 }
1330
1331 #[test]
1332 fn test_migration_v3_no_slash_keeps_model() {
1333 let mut settings = Settings::default();
1334 settings.version = 3;
1335 settings.default_model = Some("bare-model-name".to_string());
1336
1337 let migrated = Settings::migrate(settings).unwrap();
1338 assert_eq!(migrated.version, SETTINGS_VERSION);
1339 assert_eq!(migrated.default_model, Some("bare-model-name".to_string()));
1340 }
1341
1342 #[test]
1343 fn test_migration_future_version_fails() {
1344 let mut settings = Settings::default();
1345 settings.version = 9999;
1346 assert!(Settings::migrate(settings).is_err());
1347 }
1348
1349 #[test]
1352 fn test_save_and_load_roundtrip() {
1353 let tmp = tempfile::tempdir().unwrap();
1354 let settings_path = tmp.path().join("settings.toml");
1355
1356 let mut original = Settings::default();
1357 original.default_model = Some("gpt-4o".to_string());
1358 original.default_provider = Some("openai".to_string());
1359 original.theme = "dracula".to_string();
1360 original.tool_timeout_seconds = 60;
1361
1362 let content = toml::to_string_pretty(&original).unwrap();
1364 fs::write(&settings_path, &content).unwrap();
1365
1366 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1368 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1369
1370 assert_eq!(loaded.default_model, original.default_model);
1371 assert_eq!(loaded.theme, original.theme);
1372 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1373 }
1374
1375 #[test]
1376 fn test_toml_roundtrip_preserves_new_fields() {
1377 let mut settings = Settings::default();
1378 settings.default_temperature = Some(0.8);
1379 settings.max_response_tokens = Some(8192);
1380 settings.auto_compaction = false;
1381 settings.extensions_enabled = false;
1382 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1383
1384 let toml_str = toml::to_string_pretty(&settings).unwrap();
1385 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1386
1387 assert_eq!(parsed.default_temperature, Some(0.8));
1388 assert_eq!(parsed.max_response_tokens, Some(8192));
1389 assert!(!parsed.auto_compaction);
1390 assert!(!parsed.extensions_enabled);
1391 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1392 }
1393
1394 #[test]
1397 fn test_json_roundtrip() {
1398 let mut settings = Settings::default();
1399 settings.default_model = Some("gpt-4o".to_string());
1400 settings.default_provider = Some("openai".to_string());
1401 settings.theme = "dracula".to_string();
1402 settings.tool_timeout_seconds = 60;
1403 settings.default_temperature = Some(0.8);
1404 settings.max_response_tokens = Some(8192);
1405
1406 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1407 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1408
1409 assert_eq!(parsed.default_model, settings.default_model);
1410 assert_eq!(parsed.theme, settings.theme);
1411 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1412 assert_eq!(parsed.default_temperature, settings.default_temperature);
1413 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1414 }
1415
1416 #[test]
1417 fn test_json_serialize_for_format() {
1418 let mut settings = Settings::default();
1419 settings.default_model = Some("claude-3".to_string());
1420 settings.default_provider = Some("anthropic".to_string());
1421 settings.thinking_level = ThinkingLevel::Minimal;
1422
1423 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1424 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1425
1426 assert_eq!(parsed.default_model, Some("claude-3".to_string()));
1427 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1428 }
1429
1430 #[test]
1431 fn test_toml_serialize_for_format() {
1432 let mut settings = Settings::default();
1433 settings.default_model = Some("gemini-pro".to_string());
1434 settings.default_provider = Some("google".to_string());
1435 settings.thinking_level = ThinkingLevel::High;
1436
1437 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1438 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1439
1440 assert_eq!(parsed.default_model, Some("gemini-pro".to_string()));
1441 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1442 }
1443
1444 #[test]
1445 fn test_parse_from_str_json() {
1446 let json_content = r#"{
1447 "default_model": "gpt-4",
1448 "default_provider": "openai",
1449 "theme": "nord",
1450 "tool_timeout_seconds": 90
1451 }"#;
1452
1453 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1454 assert_eq!(settings.default_model, Some("gpt-4".to_string()));
1455 assert_eq!(settings.default_provider, Some("openai".to_string()));
1456 assert_eq!(settings.theme, "nord");
1457 assert_eq!(settings.tool_timeout_seconds, 90);
1458 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1460 assert!(settings.extensions_enabled);
1461 }
1462
1463 #[test]
1464 fn test_parse_from_str_toml() {
1465 let toml_content = r#"
1466default_model = "claude-opus"
1467default_provider = "anthropic"
1468theme = "monokai"
1469tool_timeout_seconds = 45
1470"#;
1471
1472 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1473 assert_eq!(settings.default_model, Some("claude-opus".to_string()));
1474 assert_eq!(settings.default_provider, Some("anthropic".to_string()));
1475 assert_eq!(settings.theme, "monokai");
1476 assert_eq!(settings.tool_timeout_seconds, 45);
1477 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1478 }
1479
1480 #[test]
1481 fn test_layer_file_json() {
1482 let base = Settings::default();
1483
1484 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1485 let json_content = r#"{
1486 "default_model": "gpt-4o",
1487 "default_provider": "openai",
1488 "theme": "dracula",
1489 "auto_compaction": false
1490 }"#;
1491 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1492
1493 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1494 assert_eq!(merged.default_model, Some("gpt-4o".to_string()));
1495 assert_eq!(merged.default_provider, Some("openai".to_string()));
1496 assert_eq!(merged.theme, "dracula");
1497 assert!(!merged.auto_compaction);
1498 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1500 assert!(merged.extensions_enabled);
1501 assert_eq!(merged.tool_timeout_seconds, 120);
1502 }
1503
1504 #[test]
1505 fn test_layer_file_json_preserves_unset() {
1506 let mut base = Settings::default();
1507 base.default_provider = Some("deepseek".to_string());
1508
1509 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1510 let json_content = r#"{ "theme": "nord" }"#;
1511 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1512
1513 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1514 assert_eq!(merged.theme, "nord");
1515 assert_eq!(merged.default_provider, Some("deepseek".to_string()));
1516 }
1517
1518 #[test]
1519 fn test_save_to_json() {
1520 let tmp = tempfile::tempdir().unwrap();
1521 let settings_path = tmp.path().join("settings.json");
1522
1523 let mut settings = Settings::default();
1524 settings.default_model = Some("gpt-4o".to_string());
1525 settings.default_provider = Some("openai".to_string());
1526 settings.theme = "dracula".to_string();
1527 settings.tool_timeout_seconds = 60;
1528
1529 settings.save_to(&settings_path).unwrap();
1530
1531 let content = fs::read_to_string(&settings_path).unwrap();
1533 let parsed: Settings = serde_json::from_str(&content).unwrap();
1534 assert_eq!(parsed.default_model, Some("gpt-4o".to_string()));
1535 assert_eq!(parsed.theme, "dracula");
1536 assert_eq!(parsed.tool_timeout_seconds, 60);
1537 }
1538
1539 #[test]
1540 fn test_save_to_toml() {
1541 let tmp = tempfile::tempdir().unwrap();
1542 let settings_path = tmp.path().join("settings.toml");
1543
1544 let mut settings = Settings::default();
1545 settings.default_model = Some("gemini-pro".to_string());
1546 settings.default_provider = Some("google".to_string());
1547 settings.theme = "monokai".to_string();
1548 settings.tool_timeout_seconds = 90;
1549
1550 settings.save_to(&settings_path).unwrap();
1551
1552 let content = fs::read_to_string(&settings_path).unwrap();
1554 let parsed: Settings = toml::from_str(&content).unwrap();
1555 assert_eq!(parsed.default_model, Some("gemini-pro".to_string()));
1556 assert_eq!(parsed.theme, "monokai");
1557 assert_eq!(parsed.tool_timeout_seconds, 90);
1558 }
1559
1560 #[test]
1561 fn test_load_from_dir_with_json_project_config() {
1562 let _guard = EnvGuard::new(&[
1563 "OXI_MODEL",
1564 "OXI_PROVIDER",
1565 "OXI_THEME",
1566 "OXI_TOOL_TIMEOUT",
1567 "OXI_TEMPERATURE",
1568 "OXI_MAX_TOKENS",
1569 "OXI_SESSION_DIR",
1570 "OXI_STREAM",
1571 "OXI_EXTENSIONS_ENABLED",
1572 ]);
1573 let tmp = tempfile::tempdir().unwrap();
1574 let oxi_dir = tmp.path().join(".oxi");
1575 fs::create_dir_all(&oxi_dir).unwrap();
1576 let settings_path = oxi_dir.join("settings.json");
1577 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1579 fs::write(&settings_path, json_content).unwrap();
1580
1581 let settings = Settings::load_from(tmp.path()).unwrap();
1582 assert_eq!(settings.default_model, Some("gemini-2.0-flash".to_string()));
1584 assert_eq!(settings.default_provider, Some("google".to_string()));
1585 }
1586
1587 #[test]
1588 fn test_find_project_settings_json_priority() {
1589 let tmp = tempfile::tempdir().unwrap();
1590 let oxi_dir = tmp.path().join(".oxi");
1591 fs::create_dir_all(&oxi_dir).unwrap();
1592
1593 let json_path = oxi_dir.join("settings.json");
1595 let toml_path = oxi_dir.join("settings.toml");
1596 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1597 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1598
1599 let found = Settings::find_project_settings(tmp.path());
1601 assert!(found.is_some());
1602 assert_eq!(
1603 found.unwrap().file_name().unwrap().to_str().unwrap(),
1604 "settings.json"
1605 );
1606 }
1607
1608 #[test]
1609 fn test_find_project_settings_json_only() {
1610 let tmp = tempfile::tempdir().unwrap();
1611 let oxi_dir = tmp.path().join(".oxi");
1612 fs::create_dir_all(&oxi_dir).unwrap();
1613
1614 let json_path = oxi_dir.join("settings.json");
1615 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1616
1617 let found = Settings::find_project_settings(tmp.path());
1618 assert!(found.is_some());
1619 assert_eq!(
1620 found.unwrap().file_name().unwrap().to_str().unwrap(),
1621 "settings.json"
1622 );
1623 }
1624
1625 #[test]
1626 fn test_find_project_settings_toml_fallback() {
1627 let tmp = tempfile::tempdir().unwrap();
1628 let oxi_dir = tmp.path().join(".oxi");
1629 fs::create_dir_all(&oxi_dir).unwrap();
1630
1631 let toml_path = oxi_dir.join("settings.toml");
1632 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1633
1634 let found = Settings::find_project_settings(tmp.path());
1635 assert!(found.is_some());
1636 assert_eq!(
1637 found.unwrap().file_name().unwrap().to_str().unwrap(),
1638 "settings.toml"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_detect_format() {
1644 let json_path = PathBuf::from("/test/settings.json");
1645 let toml_path = PathBuf::from("/test/settings.toml");
1646 let unknown_path = PathBuf::from("/test/settings");
1647
1648 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1649 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1650 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1651 }
1653
1654 #[test]
1655 fn test_settings_format_extension() {
1656 assert_eq!(SettingsFormat::Json.extension(), "json");
1657 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1658 }
1659
1660 #[test]
1661 fn test_layer_json_over_toml() {
1662 let tmp = tempfile::tempdir().unwrap();
1664 let oxi_dir = tmp.path().join(".oxi");
1665 fs::create_dir_all(&oxi_dir).unwrap();
1666
1667 let json_path = oxi_dir.join("settings.json");
1668 let toml_path = oxi_dir.join("settings.toml");
1669
1670 fs::write(&json_path, r#"{ "default_model": "json-model" }"#).unwrap();
1672 fs::write(&toml_path, r#"default_model = "toml-model""#).unwrap();
1674
1675 let settings = Settings::load_from(tmp.path()).unwrap();
1677 assert_eq!(settings.default_model, Some("json-model".to_string()));
1678 }
1679
1680 #[test]
1681 fn test_mixed_format_loading() {
1682 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1684 let toml_content = r#"
1685default_model = "loaded-via-toml"
1686theme = "loaded-theme"
1687stream_responses = false
1688"#;
1689 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1690
1691 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1692 assert_eq!(merged.default_model, Some("loaded-via-toml".to_string()));
1693 assert_eq!(merged.theme, "loaded-theme");
1694 assert!(!merged.stream_responses);
1695 }
1696
1697 #[test]
1698 fn test_merge_json_values() {
1699 let base = serde_json::json!({
1700 "version": 1,
1701 "theme": "default",
1702 "extensions": ["ext1"],
1703 "nested": {
1704 "a": 1,
1705 "b": 2
1706 }
1707 });
1708
1709 let override_ = serde_json::json!({
1710 "version": 2,
1711 "theme": "dark",
1712 "extensions": ["ext2"],
1713 "nested": {
1714 "b": 20,
1715 "c": 30
1716 }
1717 });
1718
1719 let merged = merge_json_values(base, override_);
1720
1721 assert_eq!(merged["version"], 2);
1722 assert_eq!(merged["theme"], "dark");
1723 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1725 assert_eq!(merged["nested"]["a"], 1);
1727 assert_eq!(merged["nested"]["b"], 20);
1728 assert_eq!(merged["nested"]["c"], 30);
1729 }
1730
1731 #[test]
1732 fn test_save_project_preserves_existing_format() {
1733 let tmp = tempfile::tempdir().unwrap();
1734 let oxi_dir = tmp.path().join(".oxi");
1735 fs::create_dir_all(&oxi_dir).unwrap();
1736
1737 let toml_path = oxi_dir.join("settings.toml");
1739 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1740
1741 let mut settings = Settings::default();
1742 settings.theme = "new-theme".to_string();
1743 settings.save_project(tmp.path()).unwrap();
1744
1745 let content = fs::read_to_string(&toml_path).unwrap();
1747 assert!(content.contains("new-theme"));
1748 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1749 }
1750
1751 #[test]
1752 fn test_save_project_creates_json_by_default() {
1753 let tmp = tempfile::tempdir().unwrap();
1754 let oxi_dir = tmp.path().join(".oxi");
1755 fs::create_dir_all(&oxi_dir).unwrap();
1756 let mut settings = Settings::default();
1759 settings.theme = "json-theme".to_string();
1760 settings.save_project(tmp.path()).unwrap();
1761
1762 let json_path = oxi_dir.join("settings.json");
1764 assert!(json_path.exists());
1765 let content = fs::read_to_string(&json_path).unwrap();
1766 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1767 assert!(content.contains("json-theme"));
1768 }
1769
1770 #[test]
1773 fn test_custom_provider_default_api() {
1774 use super::CustomProvider;
1775 let cp = CustomProvider {
1776 name: "test".to_string(),
1777 base_url: "https://api.test.com/v1".to_string(),
1778 api_key_env: "TEST_API_KEY".to_string(),
1779 api: super::default_custom_provider_api(),
1780 };
1781 assert_eq!(cp.api, "openai-completions");
1782 }
1783
1784 #[test]
1785 fn test_custom_provider_toml_deserialize() {
1786 let toml_content = r#"
1787[[custom_providers]]
1788name = "minimax"
1789base_url = "https://api.minimax.chat/v1"
1790api_key_env = "MINIMAX_API_KEY"
1791api = "openai-completions"
1792
1793[[custom_providers]]
1794name = "zai"
1795base_url = "https://api.z.ai/v1"
1796api_key_env = "ZAI_API_KEY"
1797api = "openai-responses"
1798"#;
1799 let settings: Settings = toml::from_str(toml_content).unwrap();
1800 assert_eq!(settings.custom_providers.len(), 2);
1801 assert_eq!(settings.custom_providers[0].name, "minimax");
1802 assert_eq!(
1803 settings.custom_providers[0].base_url,
1804 "https://api.minimax.chat/v1"
1805 );
1806 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
1807 assert_eq!(settings.custom_providers[0].api, "openai-completions");
1808 assert_eq!(settings.custom_providers[1].name, "zai");
1809 assert_eq!(settings.custom_providers[1].api, "openai-responses");
1810 }
1811
1812 #[test]
1813 fn test_custom_provider_json_deserialize() {
1814 let json_content = r#"{
1815 "custom_providers": [
1816 {
1817 "name": "minimax",
1818 "base_url": "https://api.minimax.chat/v1",
1819 "api_key_env": "MINIMAX_API_KEY",
1820 "api": "openai-completions"
1821 }
1822 ]
1823 }"#;
1824 let settings: Settings = serde_json::from_str(json_content).unwrap();
1825 assert_eq!(settings.custom_providers.len(), 1);
1826 assert_eq!(settings.custom_providers[0].name, "minimax");
1827 }
1828
1829 #[test]
1830 fn test_custom_provider_toml_roundtrip() {
1831 let mut settings = Settings::default();
1832 settings.custom_providers.push(super::CustomProvider {
1833 name: "test".to_string(),
1834 base_url: "https://api.test.com/v1".to_string(),
1835 api_key_env: "TEST_API_KEY".to_string(),
1836 api: "openai-completions".to_string(),
1837 });
1838
1839 let toml_str = toml::to_string_pretty(&settings).unwrap();
1840 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1841 assert_eq!(parsed.custom_providers.len(), 1);
1842 assert_eq!(parsed.custom_providers[0].name, "test");
1843 assert_eq!(
1844 parsed.custom_providers[0].base_url,
1845 "https://api.test.com/v1"
1846 );
1847 }
1848
1849 #[test]
1850 fn test_custom_provider_defaults_empty() {
1851 let settings = Settings::default();
1852 assert!(settings.custom_providers.is_empty());
1853 }
1854
1855 #[test]
1856 fn test_custom_provider_layer_file() {
1857 let base = Settings::default();
1858
1859 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1860 let toml_content = r#"
1861[[custom_providers]]
1862name = "my-provider"
1863base_url = "https://api.my-provider.com/v1"
1864api_key_env = "MY_PROVIDER_API_KEY"
1865"#;
1866 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1867
1868 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1869 assert_eq!(merged.custom_providers.len(), 1);
1870 assert_eq!(merged.custom_providers[0].name, "my-provider");
1871 assert_eq!(merged.custom_providers[0].api, "openai-completions");
1873 }
1874}