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