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(|| {
702 let model = self.last_used_model.as_ref()?;
707 if model.contains('/') {
708 Some(model.clone())
710 } else if let Some(ref provider) = self.last_used_provider {
711 Some(format!("{}/{}", provider, model))
713 } else {
714 Some(model.clone())
715 }
716 })
717 }
718
719 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
722 cli_provider
723 .map(String::from)
724 .or_else(|| self.last_used_provider.clone())
725 }
726
727 pub fn effective_temperature(&self) -> Option<f64> {
730 self.default_temperature
731 .or(self.temperature.map(|t| t as f64))
732 }
733
734 pub fn effective_max_tokens(&self) -> Option<usize> {
737 self.max_response_tokens
738 .or(self.max_tokens.map(|t| t as usize))
739 }
740
741 pub fn router_profile(&self) -> Option<&str> {
743 self.router_profile.as_deref()
744 }
745
746 pub fn save_last_used(model_id: &str) {
752 if let Ok(mut settings) = Self::load() {
753 if let Some((provider, model)) = model_id.split_once('/') {
754 settings.last_used_provider = Some(provider.to_string());
755 settings.last_used_model = Some(model.to_string());
756 } else {
757 settings.last_used_model = Some(model_id.to_string());
758 }
759 let _ = settings.save();
760 }
761 }
762
763 pub fn save_theme(&mut self, name: &str) -> Result<()> {
765 self.theme = name.to_string();
766 self.save()
767 }
768
769 pub fn get_theme_name(&self) -> String {
771 if self.theme.is_empty() || self.theme == "default" {
772 "oxi_dark".to_string()
773 } else {
774 self.theme.clone()
775 }
776 }
777
778 fn migrate(settings: Settings) -> Result<Settings> {
786 let mut settings = settings;
787
788 match settings.version {
789 SETTINGS_VERSION => {
790 }
792 0 => {
793 if settings.tool_timeout_seconds == 0 {
796 settings.tool_timeout_seconds = default_tool_timeout();
797 }
798 settings.version = SETTINGS_VERSION;
799
800 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
801 }
802 1 | 2 => {
803 settings.version = SETTINGS_VERSION;
805 tracing::info!(
806 "Migrated settings from version {} to {}",
807 settings.version,
808 SETTINGS_VERSION
809 );
810 }
811 3 => {
812 if let Some(model) = settings.default_model.take() {
814 if let Some((provider, model_name)) = model.split_once('/') {
815 settings.last_used_provider = Some(provider.to_string());
816 settings.last_used_model = Some(model_name.to_string());
817 } else {
818 settings.last_used_model = Some(model);
819 }
820 }
821 settings.version = SETTINGS_VERSION;
822 tracing::info!(
823 "Migrated settings from version 3 to {} (default_model → last_used_model)",
824 SETTINGS_VERSION
825 );
826 }
827 v if v > SETTINGS_VERSION => {
828 anyhow::bail!(
830 "Settings version {} is newer than supported version {}. \
831 Please update oxi.",
832 v,
833 SETTINGS_VERSION
834 );
835 }
836 v => {
837 tracing::warn!(
839 "Unknown settings version {}, attempting migration to {}",
840 v,
841 SETTINGS_VERSION
842 );
843 settings.version = SETTINGS_VERSION;
844 }
845 }
846
847 Ok(settings)
848 }
849}
850
851#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
855pub enum SettingsFormat {
856 #[default]
858 Json,
859 Toml,
861}
862
863impl SettingsFormat {
864 pub fn extension(&self) -> &'static str {
866 match self {
867 SettingsFormat::Json => "json",
868 SettingsFormat::Toml => "toml",
869 }
870 }
871}
872
873fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
877 match toml {
878 toml::Value::String(s) => serde_json::Value::String(s),
879 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
880 toml::Value::Float(f) => serde_json::Number::from_f64(f)
881 .map(serde_json::Value::Number)
882 .unwrap_or(serde_json::Value::Null),
883 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
884 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
885 toml::Value::Array(arr) => {
886 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
887 }
888 toml::Value::Table(table) => {
889 let obj = table
890 .into_iter()
891 .map(|(k, v)| (k, toml_value_to_json(v)))
892 .collect();
893 serde_json::Value::Object(obj)
894 }
895 }
896}
897
898fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
900 match (base, override_) {
901 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
903 let mut result = base_map;
904 for (key, override_value) in override_map {
905 let base_value = result.remove(&key);
906 let merged = match base_value {
907 Some(base_v) => merge_json_values(base_v, override_value),
908 None => override_value,
909 };
910 result.insert(key, merged);
911 }
912 serde_json::Value::Object(result)
913 }
914 (_, override_) => override_,
916 }
917}
918
919pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
921 match s.to_lowercase().as_str() {
922 "off" | "none" => Some(ThinkingLevel::Off),
923 "minimal" => Some(ThinkingLevel::Minimal),
924 "low" => Some(ThinkingLevel::Low),
925 "medium" | "standard" => Some(ThinkingLevel::Medium),
926 "high" | "thorough" => Some(ThinkingLevel::High),
927 "xhigh" => Some(ThinkingLevel::XHigh),
928 _ => None,
929 }
930}
931
932#[allow(dead_code)]
934fn parse_boolish(s: &str) -> Result<bool> {
935 match s.to_lowercase().as_str() {
936 "true" | "1" | "yes" | "on" => Ok(true),
937 "false" | "0" | "no" | "off" => Ok(false),
938 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
939 }
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945 use std::io::Write as IoWrite;
946 use std::sync::Mutex;
947
948 static ENV_LOCK: Mutex<()> = Mutex::new(());
950
951 struct EnvGuard {
954 saved: Vec<(String, Option<String>)>,
955 }
956
957 impl EnvGuard {
958 fn new(vars: &[&str]) -> Self {
959 let saved = vars
960 .iter()
961 .map(|&name| {
962 let old = env::var(name).ok();
963 env::remove_var(name);
964 (name.to_string(), old)
965 })
966 .collect();
967 Self { saved }
968 }
969 }
970
971 impl Drop for EnvGuard {
972 fn drop(&mut self) {
973 for (name, old) in self.saved.drain(..) {
974 match old {
975 Some(val) => env::set_var(&name, val),
976 None => env::remove_var(&name),
977 }
978 }
979 }
980 }
981
982 #[test]
985 fn test_default_settings() {
986 let settings = Settings::default();
987 assert_eq!(settings.version, SETTINGS_VERSION);
988 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
989 assert_eq!(settings.theme, "default");
990 assert!(settings.last_used_model.is_none());
991 assert!(settings.last_used_provider.is_none());
992 assert!(settings.extensions_enabled);
993 assert!(settings.auto_compaction);
994 assert_eq!(settings.tool_timeout_seconds, 120);
995 assert!(settings.stream_responses);
996 }
997
998 #[test]
999 fn test_merge_cli() {
1000 let mut settings = Settings::default();
1001 settings.last_used_model = Some("gpt-4o".to_string());
1002
1003 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1004 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1005
1006 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1007 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1008
1009 settings.merge_cli(
1011 None,
1012 None,
1013 Some(true),
1014 Some(false),
1015 Some(vec!["openai/gpt-4o".to_string()]),
1016 Some(false),
1017 );
1018 assert!(settings.enable_routing);
1019 assert!(!settings.prefer_cost_efficient);
1020 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1021 assert!(!settings.disable_fallback);
1022
1023 let mut settings2 = Settings::default();
1025 settings2.merge_cli(None, None, None, None, None, Some(true));
1026 assert!(settings2.disable_fallback);
1027 assert!(!settings2.enable_fallback);
1028 }
1029
1030 #[test]
1033 fn test_layer_file_overrides() {
1034 let base = Settings::default();
1035
1036 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1037 let toml_content = r#"
1038last_used_model = "openai/gpt-4o"
1039theme = "dracula"
1040"#;
1041 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1042
1043 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1044 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1045 assert_eq!(merged.theme, "dracula");
1046 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1048 assert!(merged.extensions_enabled);
1049 }
1050
1051 #[test]
1052 fn test_layer_file_preserves_unset() {
1053 let mut base = Settings::default();
1054 base.last_used_provider = Some("deepseek".to_string());
1055
1056 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1057 let toml_content = "theme = \"monokai\"\n";
1059 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1060
1061 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1062 assert_eq!(merged.theme, "monokai");
1063 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1064 }
1065
1066 #[test]
1067 fn test_load_from_dir_with_project_config() {
1068 let _guard = EnvGuard::new(&[
1069 "OXI_MODEL",
1070 "OXI_PROVIDER",
1071 "OXI_THEME",
1072 "OXI_TOOL_TIMEOUT",
1073 "OXI_TEMPERATURE",
1074 "OXI_MAX_TOKENS",
1075 "OXI_SESSION_DIR",
1076 "OXI_STREAM",
1077 "OXI_EXTENSIONS_ENABLED",
1078 ]);
1079 let tmp = tempfile::tempdir().unwrap();
1080 let oxi_dir = tmp.path().join(".oxi");
1081 fs::create_dir_all(&oxi_dir).unwrap();
1082 let settings_path = oxi_dir.join("settings.toml");
1083 fs::write(
1085 &settings_path,
1086 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1087 )
1088 .unwrap();
1089
1090 let settings = Settings::load_from(tmp.path()).unwrap();
1091 assert_eq!(
1093 settings.last_used_model,
1094 Some("gemini-2.0-flash".to_string())
1095 );
1096 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1097 }
1098
1099 #[test]
1100 fn test_load_from_dir_no_config() {
1101 let _guard = EnvGuard::new(&[
1103 "OXI_MODEL",
1104 "OXI_PROVIDER",
1105 "OXI_THEME",
1106 "OXI_TOOL_TIMEOUT",
1107 "OXI_TEMPERATURE",
1108 "OXI_MAX_TOKENS",
1109 "OXI_SESSION_DIR",
1110 "OXI_STREAM",
1111 "OXI_EXTENSIONS_ENABLED",
1112 ]);
1113 let tmp = tempfile::tempdir().unwrap();
1114 let settings = Settings::load_from(tmp.path()).unwrap();
1115 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1117 }
1118
1119 #[test]
1122 fn test_from_env() {
1123 let _guard = EnvGuard::new(&[
1126 "OXI_MODEL",
1128 "OXI_THEME",
1129 "OXI_TOOL_TIMEOUT",
1130 "OXI_PROVIDER",
1131 "OXI_DEFAULT_MODEL",
1132 ]);
1133
1134 let settings = Settings::from_env();
1135 assert_eq!(settings.last_used_model, None);
1137 assert_eq!(settings.theme, "default");
1138 assert_eq!(settings.tool_timeout_seconds, 120);
1139 }
1140
1141 #[test]
1142 fn test_apply_env_boolish() {
1143 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1146 env::set_var("OXI_STREAM", "false");
1147 env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1148
1149 let mut settings = Settings::default();
1150 settings.apply_env();
1151 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1155
1156 #[test]
1157 fn test_apply_env_temperature() {
1158 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1160 env::set_var("OXI_TEMPERATURE", "0.7");
1161
1162 let mut settings = Settings::default();
1163 settings.apply_env();
1164 assert_eq!(settings.default_temperature, None);
1166 }
1167
1168 #[test]
1169 fn test_env_does_not_override_when_unset() {
1170 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1171 let settings = Settings::from_env();
1172 assert!(settings.last_used_model.is_none());
1173 assert!(settings.last_used_provider.is_none());
1174 }
1175
1176 #[test]
1177 fn test_parse_thinking_level() {
1178 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1179 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1180 assert_eq!(
1181 parse_thinking_level("MINIMAL"),
1182 Some(ThinkingLevel::Minimal)
1183 );
1184 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1185 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1186 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1187 assert_eq!(
1188 parse_thinking_level("Standard"),
1189 Some(ThinkingLevel::Medium)
1190 );
1191 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1192 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1193 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1194 assert_eq!(parse_thinking_level("invalid"), None);
1195 }
1196
1197 #[test]
1198 fn test_parse_boolish() {
1199 assert!(parse_boolish("true").unwrap());
1200 assert!(parse_boolish("1").unwrap());
1201 assert!(parse_boolish("yes").unwrap());
1202 assert!(parse_boolish("ON").unwrap());
1203 assert!(!parse_boolish("false").unwrap());
1204 assert!(!parse_boolish("0").unwrap());
1205 assert!(!parse_boolish("no").unwrap());
1206 assert!(!parse_boolish("OFF").unwrap());
1207 assert!(parse_boolish("maybe").is_err());
1208 }
1209
1210 #[test]
1213 fn test_effective_model_returns_last_used() {
1214 let mut settings = Settings::default();
1215 settings.last_used_model = Some("openai/gpt-4o".to_string());
1216 assert_eq!(
1217 settings.effective_model(None),
1218 Some("openai/gpt-4o".to_string())
1219 );
1220 }
1221
1222 #[test]
1223 fn test_effective_model_cli_overrides() {
1224 let mut settings = Settings::default();
1225 settings.last_used_model = Some("openai/gpt-4o".to_string());
1226 assert_eq!(
1227 settings.effective_model(Some("anthropic/claude-3")),
1228 Some("anthropic/claude-3".to_string())
1229 );
1230 }
1231
1232 #[test]
1233 fn test_effective_model_none_when_unset() {
1234 let settings = Settings::default();
1235 assert_eq!(settings.effective_model(None), None);
1236 }
1237
1238 #[test]
1239 fn test_effective_model_falls_back_to_last_used() {
1240 let mut settings = Settings::default();
1241 settings.last_used_model = Some("anthropic/claude-3".to_string());
1242 assert_eq!(
1243 settings.effective_model(None),
1244 Some("anthropic/claude-3".to_string())
1245 );
1246 }
1247
1248 #[test]
1249 fn test_effective_model_returns_none_when_nothing_set() {
1250 let settings = Settings::default();
1251 assert_eq!(settings.effective_model(None), None);
1252 }
1253
1254 #[test]
1255 fn test_effective_temperature_prefers_f64() {
1256 let mut settings = Settings::default();
1257 settings.temperature = Some(0.5);
1258 settings.default_temperature = Some(0.7);
1259 assert_eq!(settings.effective_temperature(), Some(0.7));
1260 }
1261
1262 #[test]
1263 fn test_effective_temperature_falls_back_to_f32() {
1264 let mut settings = Settings::default();
1265 settings.temperature = Some(0.5);
1266 assert_eq!(settings.effective_temperature(), Some(0.5));
1267 }
1268
1269 #[test]
1270 fn test_effective_max_tokens_prefers_usize() {
1271 let mut settings = Settings::default();
1272 settings.max_tokens = Some(1024);
1273 settings.max_response_tokens = Some(4096);
1274 assert_eq!(settings.effective_max_tokens(), Some(4096));
1275 }
1276
1277 #[test]
1278 fn test_effective_max_tokens_falls_back_to_u32() {
1279 let mut settings = Settings::default();
1280 settings.max_tokens = Some(1024);
1281 assert_eq!(settings.effective_max_tokens(), Some(1024));
1282 }
1283
1284 #[test]
1287 fn test_effective_session_dir_default() {
1288 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1289 let settings = Settings::default();
1290 let dir = settings.effective_session_dir().unwrap();
1291 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1292 }
1293
1294 #[test]
1295 fn test_effective_session_dir_from_field() {
1296 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1297 let mut settings = Settings::default();
1298 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1299 assert_eq!(
1300 settings.effective_session_dir().unwrap(),
1301 PathBuf::from("/tmp/oxi-sessions")
1302 );
1303 }
1304
1305 #[test]
1306 fn test_effective_session_dir_env_disabled() {
1307 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1310 env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
1311 let settings = Settings::default();
1312 let dir = settings.effective_session_dir().unwrap();
1314 assert!(
1315 dir.ends_with("sessions"),
1316 "expected default sessions dir, got: {:?}",
1317 dir
1318 );
1319 }
1320
1321 #[test]
1324 fn test_migration_v0_to_v1() {
1325 let mut settings = Settings::default();
1326 settings.version = 0;
1327 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1330 assert_eq!(migrated.version, SETTINGS_VERSION);
1331 assert_eq!(migrated.tool_timeout_seconds, 120);
1332 }
1333
1334 #[test]
1335 fn test_migration_already_current() {
1336 let settings = Settings::default();
1337 let migrated = Settings::migrate(settings).unwrap();
1338 assert_eq!(migrated.version, SETTINGS_VERSION);
1339 }
1340
1341 #[test]
1342 fn test_migration_v3_to_v4_splits_model() {
1343 let mut settings = Settings::default();
1344 settings.version = 3;
1345 settings.default_model = Some("openai/gpt-4o".to_string());
1346 settings.default_provider = None;
1347
1348 let migrated = Settings::migrate(settings).unwrap();
1349 assert_eq!(migrated.version, SETTINGS_VERSION);
1350 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1351 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1352 }
1353
1354 #[test]
1355 fn test_migration_v3_no_slash_keeps_model() {
1356 let mut settings = Settings::default();
1357 settings.version = 3;
1358 settings.default_model = Some("bare-model-name".to_string());
1359
1360 let migrated = Settings::migrate(settings).unwrap();
1361 assert_eq!(migrated.version, SETTINGS_VERSION);
1362 assert_eq!(
1363 migrated.last_used_model,
1364 Some("bare-model-name".to_string())
1365 );
1366 }
1367
1368 #[test]
1369 fn test_migration_future_version_fails() {
1370 let mut settings = Settings::default();
1371 settings.version = 9999;
1372 assert!(Settings::migrate(settings).is_err());
1373 }
1374
1375 #[test]
1378 fn test_save_and_load_roundtrip() {
1379 let tmp = tempfile::tempdir().unwrap();
1380 let settings_path = tmp.path().join("settings.toml");
1381
1382 let mut original = Settings::default();
1383 original.last_used_model = Some("gpt-4o".to_string());
1384 original.last_used_provider = Some("openai".to_string());
1385 original.theme = "dracula".to_string();
1386 original.tool_timeout_seconds = 60;
1387
1388 let content = toml::to_string_pretty(&original).unwrap();
1390 fs::write(&settings_path, &content).unwrap();
1391
1392 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1394 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1395
1396 assert_eq!(loaded.last_used_model, original.last_used_model);
1397 assert_eq!(loaded.theme, original.theme);
1398 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1399 }
1400
1401 #[test]
1402 fn test_toml_roundtrip_preserves_new_fields() {
1403 let mut settings = Settings::default();
1404 settings.default_temperature = Some(0.8);
1405 settings.max_response_tokens = Some(8192);
1406 settings.auto_compaction = false;
1407 settings.extensions_enabled = false;
1408 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1409
1410 let toml_str = toml::to_string_pretty(&settings).unwrap();
1411 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1412
1413 assert_eq!(parsed.default_temperature, Some(0.8));
1414 assert_eq!(parsed.max_response_tokens, Some(8192));
1415 assert!(!parsed.auto_compaction);
1416 assert!(!parsed.extensions_enabled);
1417 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1418 }
1419
1420 #[test]
1423 fn test_json_roundtrip() {
1424 let mut settings = Settings::default();
1425 settings.last_used_model = Some("gpt-4o".to_string());
1426 settings.last_used_provider = Some("openai".to_string());
1427 settings.theme = "dracula".to_string();
1428 settings.tool_timeout_seconds = 60;
1429 settings.default_temperature = Some(0.8);
1430 settings.max_response_tokens = Some(8192);
1431
1432 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1433 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1434
1435 assert_eq!(parsed.last_used_model, settings.last_used_model);
1436 assert_eq!(parsed.theme, settings.theme);
1437 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1438 assert_eq!(parsed.default_temperature, settings.default_temperature);
1439 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1440 }
1441
1442 #[test]
1443 fn test_json_serialize_for_format() {
1444 let mut settings = Settings::default();
1445 settings.last_used_model = Some("claude-3".to_string());
1446 settings.last_used_provider = Some("anthropic".to_string());
1447 settings.thinking_level = ThinkingLevel::Minimal;
1448
1449 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1450 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1451
1452 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1453 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1454 }
1455
1456 #[test]
1457 fn test_toml_serialize_for_format() {
1458 let mut settings = Settings::default();
1459 settings.last_used_model = Some("gemini-pro".to_string());
1460 settings.last_used_provider = Some("google".to_string());
1461 settings.thinking_level = ThinkingLevel::High;
1462
1463 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1464 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1465
1466 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1467 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1468 }
1469
1470 #[test]
1471 fn test_parse_from_str_json() {
1472 let json_content = r#"{
1473 "last_used_model": "gpt-4",
1474 "last_used_provider": "openai",
1475 "theme": "nord",
1476 "tool_timeout_seconds": 90
1477 }"#;
1478
1479 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1480 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1481 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1482 assert_eq!(settings.theme, "nord");
1483 assert_eq!(settings.tool_timeout_seconds, 90);
1484 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1486 assert!(settings.extensions_enabled);
1487 }
1488
1489 #[test]
1490 fn test_parse_from_str_toml() {
1491 let toml_content = r#"
1492last_used_model = "claude-opus"
1493last_used_provider = "anthropic"
1494theme = "monokai"
1495tool_timeout_seconds = 45
1496"#;
1497
1498 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1499 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1500 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1501 assert_eq!(settings.theme, "monokai");
1502 assert_eq!(settings.tool_timeout_seconds, 45);
1503 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1504 }
1505
1506 #[test]
1507 fn test_layer_file_json() {
1508 let base = Settings::default();
1509
1510 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1511 let json_content = r#"{
1512 "last_used_model": "gpt-4o",
1513 "last_used_provider": "openai",
1514 "theme": "dracula",
1515 "auto_compaction": false
1516 }"#;
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.last_used_model, Some("gpt-4o".to_string()));
1521 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1522 assert_eq!(merged.theme, "dracula");
1523 assert!(!merged.auto_compaction);
1524 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1526 assert!(merged.extensions_enabled);
1527 assert_eq!(merged.tool_timeout_seconds, 120);
1528 }
1529
1530 #[test]
1531 fn test_layer_file_json_preserves_unset() {
1532 let mut base = Settings::default();
1533 base.last_used_provider = Some("deepseek".to_string());
1534
1535 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1536 let json_content = r#"{ "theme": "nord" }"#;
1537 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1538
1539 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1540 assert_eq!(merged.theme, "nord");
1541 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1542 }
1543
1544 #[test]
1545 fn test_save_to_json() {
1546 let tmp = tempfile::tempdir().unwrap();
1547 let settings_path = tmp.path().join("settings.json");
1548
1549 let mut settings = Settings::default();
1550 settings.last_used_model = Some("gpt-4o".to_string());
1551 settings.last_used_provider = Some("openai".to_string());
1552 settings.theme = "dracula".to_string();
1553 settings.tool_timeout_seconds = 60;
1554
1555 settings.save_to(&settings_path).unwrap();
1556
1557 let content = fs::read_to_string(&settings_path).unwrap();
1559 let parsed: Settings = serde_json::from_str(&content).unwrap();
1560 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1561 assert_eq!(parsed.theme, "dracula");
1562 assert_eq!(parsed.tool_timeout_seconds, 60);
1563 }
1564
1565 #[test]
1566 fn test_save_to_toml() {
1567 let tmp = tempfile::tempdir().unwrap();
1568 let settings_path = tmp.path().join("settings.toml");
1569
1570 let mut settings = Settings::default();
1571 settings.last_used_model = Some("gemini-pro".to_string());
1572 settings.last_used_provider = Some("google".to_string());
1573 settings.theme = "monokai".to_string();
1574 settings.tool_timeout_seconds = 90;
1575
1576 settings.save_to(&settings_path).unwrap();
1577
1578 let content = fs::read_to_string(&settings_path).unwrap();
1580 let parsed: Settings = toml::from_str(&content).unwrap();
1581 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1582 assert_eq!(parsed.theme, "monokai");
1583 assert_eq!(parsed.tool_timeout_seconds, 90);
1584 }
1585
1586 #[test]
1587 fn test_load_from_dir_with_json_project_config() {
1588 let _guard = EnvGuard::new(&[
1589 "OXI_MODEL",
1590 "OXI_PROVIDER",
1591 "OXI_THEME",
1592 "OXI_TOOL_TIMEOUT",
1593 "OXI_TEMPERATURE",
1594 "OXI_MAX_TOKENS",
1595 "OXI_SESSION_DIR",
1596 "OXI_STREAM",
1597 "OXI_EXTENSIONS_ENABLED",
1598 ]);
1599 let tmp = tempfile::tempdir().unwrap();
1600 let oxi_dir = tmp.path().join(".oxi");
1601 fs::create_dir_all(&oxi_dir).unwrap();
1602 let settings_path = oxi_dir.join("settings.json");
1603 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1605 fs::write(&settings_path, json_content).unwrap();
1606
1607 let settings = Settings::load_from(tmp.path()).unwrap();
1608 assert_eq!(
1610 settings.last_used_model,
1611 Some("gemini-2.0-flash".to_string())
1612 );
1613 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1614 }
1615
1616 #[test]
1617 fn test_find_project_settings_json_priority() {
1618 let tmp = tempfile::tempdir().unwrap();
1619 let oxi_dir = tmp.path().join(".oxi");
1620 fs::create_dir_all(&oxi_dir).unwrap();
1621
1622 let json_path = oxi_dir.join("settings.json");
1624 let toml_path = oxi_dir.join("settings.toml");
1625 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1626 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1627
1628 let found = Settings::find_project_settings(tmp.path());
1630 assert!(found.is_some());
1631 assert_eq!(
1632 found.unwrap().file_name().unwrap().to_str().unwrap(),
1633 "settings.json"
1634 );
1635 }
1636
1637 #[test]
1638 fn test_find_project_settings_json_only() {
1639 let tmp = tempfile::tempdir().unwrap();
1640 let oxi_dir = tmp.path().join(".oxi");
1641 fs::create_dir_all(&oxi_dir).unwrap();
1642
1643 let json_path = oxi_dir.join("settings.json");
1644 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1645
1646 let found = Settings::find_project_settings(tmp.path());
1647 assert!(found.is_some());
1648 assert_eq!(
1649 found.unwrap().file_name().unwrap().to_str().unwrap(),
1650 "settings.json"
1651 );
1652 }
1653
1654 #[test]
1655 fn test_find_project_settings_toml_fallback() {
1656 let tmp = tempfile::tempdir().unwrap();
1657 let oxi_dir = tmp.path().join(".oxi");
1658 fs::create_dir_all(&oxi_dir).unwrap();
1659
1660 let toml_path = oxi_dir.join("settings.toml");
1661 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1662
1663 let found = Settings::find_project_settings(tmp.path());
1664 assert!(found.is_some());
1665 assert_eq!(
1666 found.unwrap().file_name().unwrap().to_str().unwrap(),
1667 "settings.toml"
1668 );
1669 }
1670
1671 #[test]
1672 fn test_detect_format() {
1673 let json_path = PathBuf::from("/test/settings.json");
1674 let toml_path = PathBuf::from("/test/settings.toml");
1675 let unknown_path = PathBuf::from("/test/settings");
1676
1677 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1678 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1679 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1680 }
1682
1683 #[test]
1684 fn test_settings_format_extension() {
1685 assert_eq!(SettingsFormat::Json.extension(), "json");
1686 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1687 }
1688
1689 #[test]
1690 fn test_layer_json_over_toml() {
1691 let tmp = tempfile::tempdir().unwrap();
1693 let oxi_dir = tmp.path().join(".oxi");
1694 fs::create_dir_all(&oxi_dir).unwrap();
1695
1696 let json_path = oxi_dir.join("settings.json");
1697 let toml_path = oxi_dir.join("settings.toml");
1698
1699 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1701 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1703
1704 let settings = Settings::load_from(tmp.path()).unwrap();
1706 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1707 }
1708
1709 #[test]
1710 fn test_mixed_format_loading() {
1711 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1713 let toml_content = r#"
1714last_used_model = "loaded-via-toml"
1715theme = "loaded-theme"
1716stream_responses = false
1717"#;
1718 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1719
1720 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1721 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
1722 assert_eq!(merged.theme, "loaded-theme");
1723 assert!(!merged.stream_responses);
1724 }
1725
1726 #[test]
1727 fn test_merge_json_values() {
1728 let base = serde_json::json!({
1729 "version": 1,
1730 "theme": "default",
1731 "extensions": ["ext1"],
1732 "nested": {
1733 "a": 1,
1734 "b": 2
1735 }
1736 });
1737
1738 let override_ = serde_json::json!({
1739 "version": 2,
1740 "theme": "dark",
1741 "extensions": ["ext2"],
1742 "nested": {
1743 "b": 20,
1744 "c": 30
1745 }
1746 });
1747
1748 let merged = merge_json_values(base, override_);
1749
1750 assert_eq!(merged["version"], 2);
1751 assert_eq!(merged["theme"], "dark");
1752 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1754 assert_eq!(merged["nested"]["a"], 1);
1756 assert_eq!(merged["nested"]["b"], 20);
1757 assert_eq!(merged["nested"]["c"], 30);
1758 }
1759
1760 #[test]
1761 fn test_save_project_preserves_existing_format() {
1762 let tmp = tempfile::tempdir().unwrap();
1763 let oxi_dir = tmp.path().join(".oxi");
1764 fs::create_dir_all(&oxi_dir).unwrap();
1765
1766 let toml_path = oxi_dir.join("settings.toml");
1768 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1769
1770 let mut settings = Settings::default();
1771 settings.theme = "new-theme".to_string();
1772 settings.save_project(tmp.path()).unwrap();
1773
1774 let content = fs::read_to_string(&toml_path).unwrap();
1776 assert!(content.contains("new-theme"));
1777 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1778 }
1779
1780 #[test]
1781 fn test_save_project_creates_json_by_default() {
1782 let tmp = tempfile::tempdir().unwrap();
1783 let oxi_dir = tmp.path().join(".oxi");
1784 fs::create_dir_all(&oxi_dir).unwrap();
1785 let mut settings = Settings::default();
1788 settings.theme = "json-theme".to_string();
1789 settings.save_project(tmp.path()).unwrap();
1790
1791 let json_path = oxi_dir.join("settings.json");
1793 assert!(json_path.exists());
1794 let content = fs::read_to_string(&json_path).unwrap();
1795 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1796 assert!(content.contains("json-theme"));
1797 }
1798
1799 #[test]
1802 fn test_custom_provider_default_api() {
1803 use super::CustomProvider;
1804 let cp = CustomProvider {
1805 name: "test".to_string(),
1806 base_url: "https://api.test.com/v1".to_string(),
1807 api_key_env: "TEST_API_KEY".to_string(),
1808 api: super::default_custom_provider_api(),
1809 };
1810 assert_eq!(cp.api, "openai-completions");
1811 }
1812
1813 #[test]
1814 fn test_custom_provider_toml_deserialize() {
1815 let toml_content = r#"
1816[[custom_providers]]
1817name = "minimax"
1818base_url = "https://api.minimax.chat/v1"
1819api_key_env = "MINIMAX_API_KEY"
1820api = "openai-completions"
1821
1822[[custom_providers]]
1823name = "zai"
1824base_url = "https://api.z.ai/v1"
1825api_key_env = "ZAI_API_KEY"
1826api = "openai-responses"
1827"#;
1828 let settings: Settings = toml::from_str(toml_content).unwrap();
1829 assert_eq!(settings.custom_providers.len(), 2);
1830 assert_eq!(settings.custom_providers[0].name, "minimax");
1831 assert_eq!(
1832 settings.custom_providers[0].base_url,
1833 "https://api.minimax.chat/v1"
1834 );
1835 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
1836 assert_eq!(settings.custom_providers[0].api, "openai-completions");
1837 assert_eq!(settings.custom_providers[1].name, "zai");
1838 assert_eq!(settings.custom_providers[1].api, "openai-responses");
1839 }
1840
1841 #[test]
1842 fn test_custom_provider_json_deserialize() {
1843 let json_content = r#"{
1844 "custom_providers": [
1845 {
1846 "name": "minimax",
1847 "base_url": "https://api.minimax.chat/v1",
1848 "api_key_env": "MINIMAX_API_KEY",
1849 "api": "openai-completions"
1850 }
1851 ]
1852 }"#;
1853 let settings: Settings = serde_json::from_str(json_content).unwrap();
1854 assert_eq!(settings.custom_providers.len(), 1);
1855 assert_eq!(settings.custom_providers[0].name, "minimax");
1856 }
1857
1858 #[test]
1859 fn test_custom_provider_toml_roundtrip() {
1860 let mut settings = Settings::default();
1861 settings.custom_providers.push(super::CustomProvider {
1862 name: "test".to_string(),
1863 base_url: "https://api.test.com/v1".to_string(),
1864 api_key_env: "TEST_API_KEY".to_string(),
1865 api: "openai-completions".to_string(),
1866 });
1867
1868 let toml_str = toml::to_string_pretty(&settings).unwrap();
1869 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1870 assert_eq!(parsed.custom_providers.len(), 1);
1871 assert_eq!(parsed.custom_providers[0].name, "test");
1872 assert_eq!(
1873 parsed.custom_providers[0].base_url,
1874 "https://api.test.com/v1"
1875 );
1876 }
1877
1878 #[test]
1879 fn test_custom_provider_defaults_empty() {
1880 let settings = Settings::default();
1881 assert!(settings.custom_providers.is_empty());
1882 }
1883
1884 #[test]
1885 fn test_custom_provider_layer_file() {
1886 let base = Settings::default();
1887
1888 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1889 let toml_content = r#"
1890[[custom_providers]]
1891name = "my-provider"
1892base_url = "https://api.my-provider.com/v1"
1893api_key_env = "MY_PROVIDER_API_KEY"
1894"#;
1895 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1896
1897 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1898 assert_eq!(merged.custom_providers.len(), 1);
1899 assert_eq!(merged.custom_providers[0].name, "my-provider");
1900 assert_eq!(merged.custom_providers[0].api, "openai-completions");
1902 }
1903}