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