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!(settings.last_used_model, Some("gemini-2.0-flash".to_string()));
1073 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1074 }
1075
1076 #[test]
1077 fn test_load_from_dir_no_config() {
1078 let _guard = EnvGuard::new(&[
1080 "OXI_MODEL",
1081 "OXI_PROVIDER",
1082 "OXI_THEME",
1083 "OXI_TOOL_TIMEOUT",
1084 "OXI_TEMPERATURE",
1085 "OXI_MAX_TOKENS",
1086 "OXI_SESSION_DIR",
1087 "OXI_STREAM",
1088 "OXI_EXTENSIONS_ENABLED",
1089 ]);
1090 let tmp = tempfile::tempdir().unwrap();
1091 let settings = Settings::load_from(tmp.path()).unwrap();
1092 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1094 }
1095
1096 #[test]
1099 fn test_from_env() {
1100 let _guard = EnvGuard::new(&[
1103 "OXI_MODEL",
1105 "OXI_THEME",
1106 "OXI_TOOL_TIMEOUT",
1107 "OXI_PROVIDER",
1108 "OXI_DEFAULT_MODEL",
1109 ]);
1110
1111 let settings = Settings::from_env();
1112 assert_eq!(settings.last_used_model, None);
1114 assert_eq!(settings.theme, "default");
1115 assert_eq!(settings.tool_timeout_seconds, 120);
1116 }
1117
1118 #[test]
1119 fn test_apply_env_boolish() {
1120 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1123 env::set_var("OXI_STREAM", "false");
1124 env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1125
1126 let mut settings = Settings::default();
1127 settings.apply_env();
1128 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1132
1133 #[test]
1134 fn test_apply_env_temperature() {
1135 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1137 env::set_var("OXI_TEMPERATURE", "0.7");
1138
1139 let mut settings = Settings::default();
1140 settings.apply_env();
1141 assert_eq!(settings.default_temperature, None);
1143 }
1144
1145 #[test]
1146 fn test_env_does_not_override_when_unset() {
1147 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1148 let settings = Settings::from_env();
1149 assert!(settings.last_used_model.is_none());
1150 assert!(settings.last_used_provider.is_none());
1151 }
1152
1153 #[test]
1154 fn test_parse_thinking_level() {
1155 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1156 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1157 assert_eq!(
1158 parse_thinking_level("MINIMAL"),
1159 Some(ThinkingLevel::Minimal)
1160 );
1161 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1162 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1163 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1164 assert_eq!(
1165 parse_thinking_level("Standard"),
1166 Some(ThinkingLevel::Medium)
1167 );
1168 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1169 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1170 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1171 assert_eq!(parse_thinking_level("invalid"), None);
1172 }
1173
1174 #[test]
1175 fn test_parse_boolish() {
1176 assert!(parse_boolish("true").unwrap());
1177 assert!(parse_boolish("1").unwrap());
1178 assert!(parse_boolish("yes").unwrap());
1179 assert!(parse_boolish("ON").unwrap());
1180 assert!(!parse_boolish("false").unwrap());
1181 assert!(!parse_boolish("0").unwrap());
1182 assert!(!parse_boolish("no").unwrap());
1183 assert!(!parse_boolish("OFF").unwrap());
1184 assert!(parse_boolish("maybe").is_err());
1185 }
1186
1187 #[test]
1190 fn test_effective_model_returns_last_used() {
1191 let mut settings = Settings::default();
1192 settings.last_used_model = Some("openai/gpt-4o".to_string());
1193 assert_eq!(
1194 settings.effective_model(None),
1195 Some("openai/gpt-4o".to_string())
1196 );
1197 }
1198
1199 #[test]
1200 fn test_effective_model_cli_overrides() {
1201 let mut settings = Settings::default();
1202 settings.last_used_model = Some("openai/gpt-4o".to_string());
1203 assert_eq!(
1204 settings.effective_model(Some("anthropic/claude-3")),
1205 Some("anthropic/claude-3".to_string())
1206 );
1207 }
1208
1209 #[test]
1210 fn test_effective_model_none_when_unset() {
1211 let settings = Settings::default();
1212 assert_eq!(settings.effective_model(None), None);
1213 }
1214
1215 #[test]
1216 fn test_effective_model_falls_back_to_last_used() {
1217 let mut settings = Settings::default();
1218 settings.last_used_model = Some("anthropic/claude-3".to_string());
1219 assert_eq!(
1220 settings.effective_model(None),
1221 Some("anthropic/claude-3".to_string())
1222 );
1223 }
1224
1225 #[test]
1226 fn test_effective_model_returns_none_when_nothing_set() {
1227 let settings = Settings::default();
1228 assert_eq!(settings.effective_model(None), None);
1229 }
1230
1231 #[test]
1232 fn test_effective_temperature_prefers_f64() {
1233 let mut settings = Settings::default();
1234 settings.temperature = Some(0.5);
1235 settings.default_temperature = Some(0.7);
1236 assert_eq!(settings.effective_temperature(), Some(0.7));
1237 }
1238
1239 #[test]
1240 fn test_effective_temperature_falls_back_to_f32() {
1241 let mut settings = Settings::default();
1242 settings.temperature = Some(0.5);
1243 assert_eq!(settings.effective_temperature(), Some(0.5));
1244 }
1245
1246 #[test]
1247 fn test_effective_max_tokens_prefers_usize() {
1248 let mut settings = Settings::default();
1249 settings.max_tokens = Some(1024);
1250 settings.max_response_tokens = Some(4096);
1251 assert_eq!(settings.effective_max_tokens(), Some(4096));
1252 }
1253
1254 #[test]
1255 fn test_effective_max_tokens_falls_back_to_u32() {
1256 let mut settings = Settings::default();
1257 settings.max_tokens = Some(1024);
1258 assert_eq!(settings.effective_max_tokens(), Some(1024));
1259 }
1260
1261 #[test]
1264 fn test_effective_session_dir_default() {
1265 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1266 let settings = Settings::default();
1267 let dir = settings.effective_session_dir().unwrap();
1268 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1269 }
1270
1271 #[test]
1272 fn test_effective_session_dir_from_field() {
1273 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1274 let mut settings = Settings::default();
1275 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1276 assert_eq!(
1277 settings.effective_session_dir().unwrap(),
1278 PathBuf::from("/tmp/oxi-sessions")
1279 );
1280 }
1281
1282 #[test]
1283 fn test_effective_session_dir_env_disabled() {
1284 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1287 env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
1288 let settings = Settings::default();
1289 let dir = settings.effective_session_dir().unwrap();
1291 assert!(
1292 dir.ends_with("sessions"),
1293 "expected default sessions dir, got: {:?}",
1294 dir
1295 );
1296 }
1297
1298 #[test]
1301 fn test_migration_v0_to_v1() {
1302 let mut settings = Settings::default();
1303 settings.version = 0;
1304 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1307 assert_eq!(migrated.version, SETTINGS_VERSION);
1308 assert_eq!(migrated.tool_timeout_seconds, 120);
1309 }
1310
1311 #[test]
1312 fn test_migration_already_current() {
1313 let settings = Settings::default();
1314 let migrated = Settings::migrate(settings).unwrap();
1315 assert_eq!(migrated.version, SETTINGS_VERSION);
1316 }
1317
1318 #[test]
1319 fn test_migration_v3_to_v4_splits_model() {
1320 let mut settings = Settings::default();
1321 settings.version = 3;
1322 settings.default_model = Some("openai/gpt-4o".to_string());
1323 settings.default_provider = None;
1324
1325 let migrated = Settings::migrate(settings).unwrap();
1326 assert_eq!(migrated.version, SETTINGS_VERSION);
1327 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1328 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1329 }
1330
1331 #[test]
1332 fn test_migration_v3_no_slash_keeps_model() {
1333 let mut settings = Settings::default();
1334 settings.version = 3;
1335 settings.default_model = Some("bare-model-name".to_string());
1336
1337 let migrated = Settings::migrate(settings).unwrap();
1338 assert_eq!(migrated.version, SETTINGS_VERSION);
1339 assert_eq!(migrated.last_used_model, Some("bare-model-name".to_string()));
1340 }
1341
1342 #[test]
1343 fn test_migration_future_version_fails() {
1344 let mut settings = Settings::default();
1345 settings.version = 9999;
1346 assert!(Settings::migrate(settings).is_err());
1347 }
1348
1349 #[test]
1352 fn test_save_and_load_roundtrip() {
1353 let tmp = tempfile::tempdir().unwrap();
1354 let settings_path = tmp.path().join("settings.toml");
1355
1356 let mut original = Settings::default();
1357 original.last_used_model = Some("gpt-4o".to_string());
1358 original.last_used_provider = Some("openai".to_string());
1359 original.theme = "dracula".to_string();
1360 original.tool_timeout_seconds = 60;
1361
1362 let content = toml::to_string_pretty(&original).unwrap();
1364 fs::write(&settings_path, &content).unwrap();
1365
1366 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1368 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1369
1370 assert_eq!(loaded.last_used_model, original.last_used_model);
1371 assert_eq!(loaded.theme, original.theme);
1372 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1373 }
1374
1375 #[test]
1376 fn test_toml_roundtrip_preserves_new_fields() {
1377 let mut settings = Settings::default();
1378 settings.default_temperature = Some(0.8);
1379 settings.max_response_tokens = Some(8192);
1380 settings.auto_compaction = false;
1381 settings.extensions_enabled = false;
1382 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1383
1384 let toml_str = toml::to_string_pretty(&settings).unwrap();
1385 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1386
1387 assert_eq!(parsed.default_temperature, Some(0.8));
1388 assert_eq!(parsed.max_response_tokens, Some(8192));
1389 assert!(!parsed.auto_compaction);
1390 assert!(!parsed.extensions_enabled);
1391 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1392 }
1393
1394 #[test]
1397 fn test_json_roundtrip() {
1398 let mut settings = Settings::default();
1399 settings.last_used_model = Some("gpt-4o".to_string());
1400 settings.last_used_provider = Some("openai".to_string());
1401 settings.theme = "dracula".to_string();
1402 settings.tool_timeout_seconds = 60;
1403 settings.default_temperature = Some(0.8);
1404 settings.max_response_tokens = Some(8192);
1405
1406 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1407 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1408
1409 assert_eq!(parsed.last_used_model, settings.last_used_model);
1410 assert_eq!(parsed.theme, settings.theme);
1411 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1412 assert_eq!(parsed.default_temperature, settings.default_temperature);
1413 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1414 }
1415
1416 #[test]
1417 fn test_json_serialize_for_format() {
1418 let mut settings = Settings::default();
1419 settings.last_used_model = Some("claude-3".to_string());
1420 settings.last_used_provider = Some("anthropic".to_string());
1421 settings.thinking_level = ThinkingLevel::Minimal;
1422
1423 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1424 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1425
1426 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1427 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1428 }
1429
1430 #[test]
1431 fn test_toml_serialize_for_format() {
1432 let mut settings = Settings::default();
1433 settings.last_used_model = Some("gemini-pro".to_string());
1434 settings.last_used_provider = Some("google".to_string());
1435 settings.thinking_level = ThinkingLevel::High;
1436
1437 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1438 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1439
1440 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1441 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1442 }
1443
1444 #[test]
1445 fn test_parse_from_str_json() {
1446 let json_content = r#"{
1447 "last_used_model": "gpt-4",
1448 "last_used_provider": "openai",
1449 "theme": "nord",
1450 "tool_timeout_seconds": 90
1451 }"#;
1452
1453 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1454 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1455 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1456 assert_eq!(settings.theme, "nord");
1457 assert_eq!(settings.tool_timeout_seconds, 90);
1458 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1460 assert!(settings.extensions_enabled);
1461 }
1462
1463 #[test]
1464 fn test_parse_from_str_toml() {
1465 let toml_content = r#"
1466last_used_model = "claude-opus"
1467last_used_provider = "anthropic"
1468theme = "monokai"
1469tool_timeout_seconds = 45
1470"#;
1471
1472 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1473 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1474 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1475 assert_eq!(settings.theme, "monokai");
1476 assert_eq!(settings.tool_timeout_seconds, 45);
1477 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1478 }
1479
1480 #[test]
1481 fn test_layer_file_json() {
1482 let base = Settings::default();
1483
1484 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1485 let json_content = r#"{
1486 "last_used_model": "gpt-4o",
1487 "last_used_provider": "openai",
1488 "theme": "dracula",
1489 "auto_compaction": false
1490 }"#;
1491 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1492
1493 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1494 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1495 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1496 assert_eq!(merged.theme, "dracula");
1497 assert!(!merged.auto_compaction);
1498 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1500 assert!(merged.extensions_enabled);
1501 assert_eq!(merged.tool_timeout_seconds, 120);
1502 }
1503
1504 #[test]
1505 fn test_layer_file_json_preserves_unset() {
1506 let mut base = Settings::default();
1507 base.last_used_provider = Some("deepseek".to_string());
1508
1509 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1510 let json_content = r#"{ "theme": "nord" }"#;
1511 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1512
1513 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1514 assert_eq!(merged.theme, "nord");
1515 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1516 }
1517
1518 #[test]
1519 fn test_save_to_json() {
1520 let tmp = tempfile::tempdir().unwrap();
1521 let settings_path = tmp.path().join("settings.json");
1522
1523 let mut settings = Settings::default();
1524 settings.last_used_model = Some("gpt-4o".to_string());
1525 settings.last_used_provider = Some("openai".to_string());
1526 settings.theme = "dracula".to_string();
1527 settings.tool_timeout_seconds = 60;
1528
1529 settings.save_to(&settings_path).unwrap();
1530
1531 let content = fs::read_to_string(&settings_path).unwrap();
1533 let parsed: Settings = serde_json::from_str(&content).unwrap();
1534 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1535 assert_eq!(parsed.theme, "dracula");
1536 assert_eq!(parsed.tool_timeout_seconds, 60);
1537 }
1538
1539 #[test]
1540 fn test_save_to_toml() {
1541 let tmp = tempfile::tempdir().unwrap();
1542 let settings_path = tmp.path().join("settings.toml");
1543
1544 let mut settings = Settings::default();
1545 settings.last_used_model = Some("gemini-pro".to_string());
1546 settings.last_used_provider = Some("google".to_string());
1547 settings.theme = "monokai".to_string();
1548 settings.tool_timeout_seconds = 90;
1549
1550 settings.save_to(&settings_path).unwrap();
1551
1552 let content = fs::read_to_string(&settings_path).unwrap();
1554 let parsed: Settings = toml::from_str(&content).unwrap();
1555 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1556 assert_eq!(parsed.theme, "monokai");
1557 assert_eq!(parsed.tool_timeout_seconds, 90);
1558 }
1559
1560 #[test]
1561 fn test_load_from_dir_with_json_project_config() {
1562 let _guard = EnvGuard::new(&[
1563 "OXI_MODEL",
1564 "OXI_PROVIDER",
1565 "OXI_THEME",
1566 "OXI_TOOL_TIMEOUT",
1567 "OXI_TEMPERATURE",
1568 "OXI_MAX_TOKENS",
1569 "OXI_SESSION_DIR",
1570 "OXI_STREAM",
1571 "OXI_EXTENSIONS_ENABLED",
1572 ]);
1573 let tmp = tempfile::tempdir().unwrap();
1574 let oxi_dir = tmp.path().join(".oxi");
1575 fs::create_dir_all(&oxi_dir).unwrap();
1576 let settings_path = oxi_dir.join("settings.json");
1577 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1579 fs::write(&settings_path, json_content).unwrap();
1580
1581 let settings = Settings::load_from(tmp.path()).unwrap();
1582 assert_eq!(settings.last_used_model, Some("gemini-2.0-flash".to_string()));
1584 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1585 }
1586
1587 #[test]
1588 fn test_find_project_settings_json_priority() {
1589 let tmp = tempfile::tempdir().unwrap();
1590 let oxi_dir = tmp.path().join(".oxi");
1591 fs::create_dir_all(&oxi_dir).unwrap();
1592
1593 let json_path = oxi_dir.join("settings.json");
1595 let toml_path = oxi_dir.join("settings.toml");
1596 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1597 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1598
1599 let found = Settings::find_project_settings(tmp.path());
1601 assert!(found.is_some());
1602 assert_eq!(
1603 found.unwrap().file_name().unwrap().to_str().unwrap(),
1604 "settings.json"
1605 );
1606 }
1607
1608 #[test]
1609 fn test_find_project_settings_json_only() {
1610 let tmp = tempfile::tempdir().unwrap();
1611 let oxi_dir = tmp.path().join(".oxi");
1612 fs::create_dir_all(&oxi_dir).unwrap();
1613
1614 let json_path = oxi_dir.join("settings.json");
1615 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1616
1617 let found = Settings::find_project_settings(tmp.path());
1618 assert!(found.is_some());
1619 assert_eq!(
1620 found.unwrap().file_name().unwrap().to_str().unwrap(),
1621 "settings.json"
1622 );
1623 }
1624
1625 #[test]
1626 fn test_find_project_settings_toml_fallback() {
1627 let tmp = tempfile::tempdir().unwrap();
1628 let oxi_dir = tmp.path().join(".oxi");
1629 fs::create_dir_all(&oxi_dir).unwrap();
1630
1631 let toml_path = oxi_dir.join("settings.toml");
1632 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1633
1634 let found = Settings::find_project_settings(tmp.path());
1635 assert!(found.is_some());
1636 assert_eq!(
1637 found.unwrap().file_name().unwrap().to_str().unwrap(),
1638 "settings.toml"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_detect_format() {
1644 let json_path = PathBuf::from("/test/settings.json");
1645 let toml_path = PathBuf::from("/test/settings.toml");
1646 let unknown_path = PathBuf::from("/test/settings");
1647
1648 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1649 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1650 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1651 }
1653
1654 #[test]
1655 fn test_settings_format_extension() {
1656 assert_eq!(SettingsFormat::Json.extension(), "json");
1657 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1658 }
1659
1660 #[test]
1661 fn test_layer_json_over_toml() {
1662 let tmp = tempfile::tempdir().unwrap();
1664 let oxi_dir = tmp.path().join(".oxi");
1665 fs::create_dir_all(&oxi_dir).unwrap();
1666
1667 let json_path = oxi_dir.join("settings.json");
1668 let toml_path = oxi_dir.join("settings.toml");
1669
1670 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1672 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1674
1675 let settings = Settings::load_from(tmp.path()).unwrap();
1677 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1678 }
1679
1680 #[test]
1681 fn test_mixed_format_loading() {
1682 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1684 let toml_content = r#"
1685last_used_model = "loaded-via-toml"
1686theme = "loaded-theme"
1687stream_responses = false
1688"#;
1689 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1690
1691 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1692 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
1693 assert_eq!(merged.theme, "loaded-theme");
1694 assert!(!merged.stream_responses);
1695 }
1696
1697 #[test]
1698 fn test_merge_json_values() {
1699 let base = serde_json::json!({
1700 "version": 1,
1701 "theme": "default",
1702 "extensions": ["ext1"],
1703 "nested": {
1704 "a": 1,
1705 "b": 2
1706 }
1707 });
1708
1709 let override_ = serde_json::json!({
1710 "version": 2,
1711 "theme": "dark",
1712 "extensions": ["ext2"],
1713 "nested": {
1714 "b": 20,
1715 "c": 30
1716 }
1717 });
1718
1719 let merged = merge_json_values(base, override_);
1720
1721 assert_eq!(merged["version"], 2);
1722 assert_eq!(merged["theme"], "dark");
1723 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1725 assert_eq!(merged["nested"]["a"], 1);
1727 assert_eq!(merged["nested"]["b"], 20);
1728 assert_eq!(merged["nested"]["c"], 30);
1729 }
1730
1731 #[test]
1732 fn test_save_project_preserves_existing_format() {
1733 let tmp = tempfile::tempdir().unwrap();
1734 let oxi_dir = tmp.path().join(".oxi");
1735 fs::create_dir_all(&oxi_dir).unwrap();
1736
1737 let toml_path = oxi_dir.join("settings.toml");
1739 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1740
1741 let mut settings = Settings::default();
1742 settings.theme = "new-theme".to_string();
1743 settings.save_project(tmp.path()).unwrap();
1744
1745 let content = fs::read_to_string(&toml_path).unwrap();
1747 assert!(content.contains("new-theme"));
1748 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1749 }
1750
1751 #[test]
1752 fn test_save_project_creates_json_by_default() {
1753 let tmp = tempfile::tempdir().unwrap();
1754 let oxi_dir = tmp.path().join(".oxi");
1755 fs::create_dir_all(&oxi_dir).unwrap();
1756 let mut settings = Settings::default();
1759 settings.theme = "json-theme".to_string();
1760 settings.save_project(tmp.path()).unwrap();
1761
1762 let json_path = oxi_dir.join("settings.json");
1764 assert!(json_path.exists());
1765 let content = fs::read_to_string(&json_path).unwrap();
1766 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1767 assert!(content.contains("json-theme"));
1768 }
1769
1770 #[test]
1773 fn test_custom_provider_default_api() {
1774 use super::CustomProvider;
1775 let cp = CustomProvider {
1776 name: "test".to_string(),
1777 base_url: "https://api.test.com/v1".to_string(),
1778 api_key_env: "TEST_API_KEY".to_string(),
1779 api: super::default_custom_provider_api(),
1780 };
1781 assert_eq!(cp.api, "openai-completions");
1782 }
1783
1784 #[test]
1785 fn test_custom_provider_toml_deserialize() {
1786 let toml_content = r#"
1787[[custom_providers]]
1788name = "minimax"
1789base_url = "https://api.minimax.chat/v1"
1790api_key_env = "MINIMAX_API_KEY"
1791api = "openai-completions"
1792
1793[[custom_providers]]
1794name = "zai"
1795base_url = "https://api.z.ai/v1"
1796api_key_env = "ZAI_API_KEY"
1797api = "openai-responses"
1798"#;
1799 let settings: Settings = toml::from_str(toml_content).unwrap();
1800 assert_eq!(settings.custom_providers.len(), 2);
1801 assert_eq!(settings.custom_providers[0].name, "minimax");
1802 assert_eq!(
1803 settings.custom_providers[0].base_url,
1804 "https://api.minimax.chat/v1"
1805 );
1806 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
1807 assert_eq!(settings.custom_providers[0].api, "openai-completions");
1808 assert_eq!(settings.custom_providers[1].name, "zai");
1809 assert_eq!(settings.custom_providers[1].api, "openai-responses");
1810 }
1811
1812 #[test]
1813 fn test_custom_provider_json_deserialize() {
1814 let json_content = r#"{
1815 "custom_providers": [
1816 {
1817 "name": "minimax",
1818 "base_url": "https://api.minimax.chat/v1",
1819 "api_key_env": "MINIMAX_API_KEY",
1820 "api": "openai-completions"
1821 }
1822 ]
1823 }"#;
1824 let settings: Settings = serde_json::from_str(json_content).unwrap();
1825 assert_eq!(settings.custom_providers.len(), 1);
1826 assert_eq!(settings.custom_providers[0].name, "minimax");
1827 }
1828
1829 #[test]
1830 fn test_custom_provider_toml_roundtrip() {
1831 let mut settings = Settings::default();
1832 settings.custom_providers.push(super::CustomProvider {
1833 name: "test".to_string(),
1834 base_url: "https://api.test.com/v1".to_string(),
1835 api_key_env: "TEST_API_KEY".to_string(),
1836 api: "openai-completions".to_string(),
1837 });
1838
1839 let toml_str = toml::to_string_pretty(&settings).unwrap();
1840 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1841 assert_eq!(parsed.custom_providers.len(), 1);
1842 assert_eq!(parsed.custom_providers[0].name, "test");
1843 assert_eq!(
1844 parsed.custom_providers[0].base_url,
1845 "https://api.test.com/v1"
1846 );
1847 }
1848
1849 #[test]
1850 fn test_custom_provider_defaults_empty() {
1851 let settings = Settings::default();
1852 assert!(settings.custom_providers.is_empty());
1853 }
1854
1855 #[test]
1856 fn test_custom_provider_layer_file() {
1857 let base = Settings::default();
1858
1859 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1860 let toml_content = r#"
1861[[custom_providers]]
1862name = "my-provider"
1863base_url = "https://api.my-provider.com/v1"
1864api_key_env = "MY_PROVIDER_API_KEY"
1865"#;
1866 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1867
1868 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1869 assert_eq!(merged.custom_providers.len(), 1);
1870 assert_eq!(merged.custom_providers[0].name, "my-provider");
1871 assert_eq!(merged.custom_providers[0].api, "openai-completions");
1873 }
1874}