1use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::env;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19const SETTINGS_VERSION: u32 = 4;
21
22#[allow(dead_code)]
25const ENV_PREFIX: &str = "OXI_";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum ThinkingLevel {
31 #[default]
33 Off,
34 Minimal,
36 Low,
38 Medium,
40 High,
42 XHigh,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CustomProvider {
52 pub name: String,
54 pub base_url: String,
56 pub api_key_env: String,
58 #[serde(default = "default_custom_provider_api")]
60 pub api: String,
61}
62
63fn default_custom_provider_api() -> String {
64 "openai-completions".to_string()
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Settings {
70 #[serde(default)]
73 pub version: u32,
74
75 #[serde(default = "default_thinking_level")]
78 pub thinking_level: ThinkingLevel,
79
80 #[serde(default = "default_theme")]
82 pub theme: String,
83
84 pub default_model: Option<String>,
86
87 pub default_provider: Option<String>,
89
90 #[serde(default)]
92 pub last_used_model: Option<String>,
93
94 #[serde(default)]
96 pub last_used_provider: Option<String>,
97
98 pub max_tokens: Option<u32>,
100
101 pub temperature: Option<f32>,
103
104 pub default_temperature: Option<f64>,
106
107 pub max_response_tokens: Option<usize>,
109
110 #[serde(default = "default_session_history_size")]
113 pub session_history_size: usize,
114
115 pub session_dir: Option<PathBuf>,
117
118 #[serde(default = "default_true")]
121 pub stream_responses: bool,
122
123 #[serde(default = "default_true")]
125 pub extensions_enabled: bool,
126
127 #[serde(default = "default_true")]
129 pub auto_compaction: bool,
130
131 #[serde(default)]
134 pub disabled_tools: Vec<String>,
135
136 #[serde(default = "default_tool_timeout")]
139 pub tool_timeout_seconds: u64,
140
141 #[serde(default)]
144 pub extensions: Vec<String>,
145
146 #[serde(default)]
148 pub skills: Vec<String>,
149
150 #[serde(default)]
152 pub prompts: Vec<String>,
153
154 #[serde(default)]
156 pub themes: Vec<String>,
157
158 #[serde(default)]
161 pub custom_providers: Vec<CustomProvider>,
162
163 #[serde(default)]
168 pub dynamic_models: HashMap<String, Vec<String>>,
169}
170
171fn default_theme() -> String {
172 "default".to_string()
173}
174
175fn default_thinking_level() -> ThinkingLevel {
176 ThinkingLevel::Medium
177}
178
179fn default_session_history_size() -> usize {
180 100
181}
182
183fn default_true() -> bool {
184 true
185}
186
187fn default_tool_timeout() -> u64 {
188 120
189}
190
191impl Default for Settings {
192 fn default() -> Self {
193 Self {
194 version: SETTINGS_VERSION,
195 thinking_level: ThinkingLevel::Medium,
196 theme: default_theme(),
197 default_model: None,
198 default_provider: None,
199 last_used_model: None,
200 last_used_provider: None,
201 max_tokens: None,
202 temperature: None,
203 default_temperature: None,
204 max_response_tokens: None,
205 session_history_size: default_session_history_size(),
206 session_dir: None,
207 stream_responses: true,
208 extensions_enabled: true,
209 auto_compaction: true,
210 disabled_tools: Vec::new(),
211 tool_timeout_seconds: default_tool_timeout(),
212 extensions: Vec::new(),
213 skills: Vec::new(),
214 prompts: Vec::new(),
215 themes: Vec::new(),
216 custom_providers: Vec::new(),
217 dynamic_models: HashMap::new(),
218 }
219 }
220}
221
222impl Settings {
223 pub fn settings_dir() -> Result<PathBuf> {
227 let base = dirs::home_dir().context("Cannot determine home directory")?;
228 Ok(base.join(".oxi"))
229 }
230
231 pub fn settings_toml_path() -> Result<PathBuf> {
233 Ok(Self::settings_dir()?.join("settings.toml"))
234 }
235
236 pub fn settings_json_path() -> Result<PathBuf> {
238 Ok(Self::settings_dir()?.join("settings.json"))
239 }
240
241 pub fn settings_path() -> Result<PathBuf> {
248 let json_path = Self::settings_json_path()?;
249 let toml_path = Self::settings_toml_path()?;
250
251 if json_path.exists() && toml_path.exists() {
252 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
254 return Ok(json_path);
255 }
256
257 if json_path.exists() {
258 return Ok(json_path);
259 }
260
261 if toml_path.exists() {
262 return Ok(toml_path);
263 }
264
265 Ok(json_path)
267 }
268
269 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
274 let json_path = Self::settings_json_path()?;
275 let toml_path = Self::settings_toml_path()?;
276
277 let (primary, secondary) = if prefer_json {
278 (&json_path, &toml_path)
279 } else {
280 (&toml_path, &json_path)
281 };
282
283 if primary.exists() {
284 return Ok(primary.clone());
285 }
286
287 if secondary.exists() {
288 return Ok(secondary.clone());
289 }
290
291 Ok(primary.clone())
293 }
294
295 pub fn detect_format(path: &Path) -> SettingsFormat {
297 match path.extension().and_then(|e| e.to_str()) {
298 Some("json") => SettingsFormat::Json,
299 Some("toml") => SettingsFormat::Toml,
300 _ => SettingsFormat::Json, }
302 }
303
304 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
309 let mut dir = start_dir.to_path_buf();
310 loop {
311 let json_candidate = dir.join(".oxi").join("settings.json");
313 if json_candidate.exists() {
314 return Some(json_candidate);
315 }
316
317 let toml_candidate = dir.join(".oxi").join("settings.toml");
318 if toml_candidate.exists() {
319 return Some(toml_candidate);
320 }
321
322 if !dir.pop() {
323 return None;
324 }
325 }
326 }
327
328 pub fn effective_session_dir(&self) -> Result<PathBuf> {
332 if let Some(ref dir) = self.session_dir {
333 return Ok(dir.clone());
334 }
335 Ok(Self::settings_dir()?.join("sessions"))
336 }
337
338 pub fn load() -> Result<Self> {
356 Self::load_from_cwd()
357 }
358
359 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
361 let mut settings = Settings::default();
363
364 if let Ok(global_path) = Self::settings_path() {
366 if global_path.exists() {
367 settings = Self::layer_file(&settings, &global_path)?;
368 }
369 }
370
371 if let Some(project_path) = Self::find_project_settings(dir) {
373 settings = Self::layer_file(&settings, &project_path)?;
374 }
375
376 settings.apply_env();
378
379 settings = Self::migrate(settings)?;
381
382 Ok(settings)
383 }
384
385 pub fn load_from_cwd() -> Result<Self> {
387 let cwd = env::current_dir().context("Cannot determine current directory")?;
388 Self::load_from(&cwd)
389 }
390
391 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
397 let content = fs::read_to_string(path)
398 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
399
400 let format = Self::detect_format(path);
401 let overlay: serde_json::Value = match format {
402 SettingsFormat::Toml => {
403 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
404 format!("Failed to parse TOML settings from {}", path.display())
405 })?;
406 toml_value_to_json(toml_value)
408 }
409 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
410 format!("Failed to parse JSON settings from {}", path.display())
411 })?,
412 };
413
414 let base_json =
418 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
419
420 let merged = merge_json_values(base_json, overlay);
421 let result: Settings =
422 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
423
424 Ok(result)
425 }
426
427 #[allow(dead_code)]
453 pub fn apply_env(&mut self) {
454 }
458
459 #[allow(dead_code)]
465 pub fn from_env() -> Self {
466 Self::default()
467 }
468
469 pub fn save(&self) -> Result<()> {
476 let dir = Self::settings_dir()?;
477 let path = Self::settings_path()?;
478
479 if !dir.exists() {
480 fs::create_dir_all(&dir).with_context(|| {
481 format!("Failed to create settings directory {}", dir.display())
482 })?;
483 }
484
485 let format = Self::detect_format(&path);
486 let content = Self::serialize_for_format(self, format)?;
487
488 let tmp_path = path.with_extension("tmp");
490 fs::write(&tmp_path, &content)
491 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
492 fs::rename(&tmp_path, &path)
493 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
494
495 Ok(())
496 }
497
498 pub fn save_to(&self, path: &Path) -> Result<()> {
500 if let Some(parent) = path.parent() {
501 if !parent.exists() {
502 fs::create_dir_all(parent)
503 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
504 }
505 }
506
507 let format = Self::detect_format(path);
508 let content = Self::serialize_for_format(self, format)?;
509
510 let tmp_path = path.with_extension("tmp");
512 fs::write(&tmp_path, &content)
513 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
514 fs::rename(&tmp_path, path)
515 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
516
517 Ok(())
518 }
519
520 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
524 let dir = project_dir.join(".oxi");
525
526 if !dir.exists() {
527 fs::create_dir_all(&dir).with_context(|| {
528 format!(
529 "Failed to create project settings directory {}",
530 dir.display()
531 )
532 })?;
533 }
534
535 let json_path = dir.join("settings.json");
537 let toml_path = dir.join("settings.toml");
538
539 let path = if json_path.exists() {
540 &json_path
541 } else if toml_path.exists() {
542 &toml_path
543 } else {
544 &json_path
546 };
547
548 let format = Self::detect_format(path);
549 let content = Self::serialize_for_format(self, format)?;
550
551 let tmp_path = path.with_extension("tmp");
553 fs::write(&tmp_path, &content)
554 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
555 fs::rename(&tmp_path, path)
556 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
557
558 Ok(())
559 }
560
561 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
563 match format {
564 SettingsFormat::Toml => {
565 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
566 }
567 SettingsFormat::Json => serde_json::to_string_pretty(settings)
568 .context("Failed to serialize settings to JSON"),
569 }
570 }
571
572 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
574 match format {
575 SettingsFormat::Toml => {
576 toml::from_str(content).context("Failed to parse TOML settings")
577 }
578 SettingsFormat::Json => {
579 serde_json::from_str(content).context("Failed to parse JSON settings")
580 }
581 }
582 }
583
584 pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
588 if let Some(m) = model {
589 self.default_model = Some(m);
590 }
591 if let Some(p) = provider {
592 self.default_provider = Some(p);
593 }
594 }
595
596 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
600 cli_model
601 .map(String::from)
602 .or_else(|| {
603 if let (Some(provider), Some(model)) = (&self.default_provider, &self.default_model)
605 {
606 Some(format!("{}/{}", provider, model))
607 } else {
608 self.default_model.clone()
609 }
610 })
611 .or_else(|| self.last_used_model.clone())
612 }
613
614 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
617 cli_provider
618 .map(String::from)
619 .or_else(|| self.default_provider.clone())
620 .or_else(|| self.last_used_provider.clone())
621 }
622
623 pub fn effective_temperature(&self) -> Option<f64> {
626 self.default_temperature
627 .or(self.temperature.map(|t| t as f64))
628 }
629
630 pub fn effective_max_tokens(&self) -> Option<usize> {
633 self.max_response_tokens
634 .or(self.max_tokens.map(|t| t as usize))
635 }
636
637 pub fn save_last_used(model_id: &str) {
641 if let Ok(mut settings) = Self::load() {
642 let parts: Vec<&str> = model_id.splitn(2, '/').collect();
643 settings.last_used_model = Some(model_id.to_string());
644 settings.last_used_provider = parts.first().map(|s| s.to_string());
645 let _ = settings.save();
646 }
647 }
648
649 pub fn save_theme(&mut self, name: &str) -> Result<()> {
651 self.theme = name.to_string();
652 self.save()
653 }
654
655 pub fn get_theme_name(&self) -> String {
657 if self.theme.is_empty() || self.theme == "default" {
658 "oxi_dark".to_string()
659 } else {
660 self.theme.clone()
661 }
662 }
663
664 fn migrate(settings: Settings) -> Result<Settings> {
672 let mut settings = settings;
673
674 match settings.version {
675 SETTINGS_VERSION => {
676 }
678 0 => {
679 if settings.tool_timeout_seconds == 0 {
682 settings.tool_timeout_seconds = default_tool_timeout();
683 }
684 settings.version = SETTINGS_VERSION;
685
686 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
687 }
688 1 | 2 => {
689 settings.version = SETTINGS_VERSION;
691 tracing::info!(
692 "Migrated settings from version {} to {}",
693 settings.version,
694 SETTINGS_VERSION
695 );
696 }
697 3 => {
698 if let Some(model) = settings.default_model.take() {
700 if let Some((provider, model_name)) = model.split_once('/') {
701 settings.default_provider = Some(provider.to_string());
702 settings.default_model = Some(model_name.to_string());
703 } else {
704 settings.default_model = Some(model);
706 }
707 }
708 settings.version = SETTINGS_VERSION;
709 tracing::info!(
710 "Migrated settings from version 3 to {} (split default_model into provider + model)",
711 SETTINGS_VERSION
712 );
713 }
714 v if v > SETTINGS_VERSION => {
715 anyhow::bail!(
717 "Settings version {} is newer than supported version {}. \
718 Please update oxi.",
719 v,
720 SETTINGS_VERSION
721 );
722 }
723 v => {
724 tracing::warn!(
726 "Unknown settings version {}, attempting migration to {}",
727 v,
728 SETTINGS_VERSION
729 );
730 settings.version = SETTINGS_VERSION;
731 }
732 }
733
734 Ok(settings)
735 }
736}
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
742pub enum SettingsFormat {
743 #[default]
745 Json,
746 Toml,
748}
749
750impl SettingsFormat {
751 pub fn extension(&self) -> &'static str {
753 match self {
754 SettingsFormat::Json => "json",
755 SettingsFormat::Toml => "toml",
756 }
757 }
758}
759
760fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
764 match toml {
765 toml::Value::String(s) => serde_json::Value::String(s),
766 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
767 toml::Value::Float(f) => serde_json::Number::from_f64(f)
768 .map(serde_json::Value::Number)
769 .unwrap_or(serde_json::Value::Null),
770 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
771 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
772 toml::Value::Array(arr) => {
773 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
774 }
775 toml::Value::Table(table) => {
776 let obj = table
777 .into_iter()
778 .map(|(k, v)| (k, toml_value_to_json(v)))
779 .collect();
780 serde_json::Value::Object(obj)
781 }
782 }
783}
784
785fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
787 match (base, override_) {
788 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
790 let mut result = base_map;
791 for (key, override_value) in override_map {
792 let base_value = result.remove(&key);
793 let merged = match base_value {
794 Some(base_v) => merge_json_values(base_v, override_value),
795 None => override_value,
796 };
797 result.insert(key, merged);
798 }
799 serde_json::Value::Object(result)
800 }
801 (_, override_) => override_,
803 }
804}
805
806pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
808 match s.to_lowercase().as_str() {
809 "off" | "none" => Some(ThinkingLevel::Off),
810 "minimal" => Some(ThinkingLevel::Minimal),
811 "low" => Some(ThinkingLevel::Low),
812 "medium" | "standard" => Some(ThinkingLevel::Medium),
813 "high" | "thorough" => Some(ThinkingLevel::High),
814 "xhigh" => Some(ThinkingLevel::XHigh),
815 _ => None,
816 }
817}
818
819#[allow(dead_code)]
821fn parse_boolish(s: &str) -> Result<bool> {
822 match s.to_lowercase().as_str() {
823 "true" | "1" | "yes" | "on" => Ok(true),
824 "false" | "0" | "no" | "off" => Ok(false),
825 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
826 }
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832 use std::io::Write as IoWrite;
833 use std::sync::Mutex;
834
835 static ENV_LOCK: Mutex<()> = Mutex::new(());
837
838 struct EnvGuard {
841 saved: Vec<(String, Option<String>)>,
842 }
843
844 impl EnvGuard {
845 fn new(vars: &[&str]) -> Self {
846 let saved = vars
847 .iter()
848 .map(|&name| {
849 let old = env::var(name).ok();
850 env::remove_var(name);
851 (name.to_string(), old)
852 })
853 .collect();
854 Self { saved }
855 }
856 }
857
858 impl Drop for EnvGuard {
859 fn drop(&mut self) {
860 for (name, old) in self.saved.drain(..) {
861 match old {
862 Some(val) => env::set_var(&name, val),
863 None => env::remove_var(&name),
864 }
865 }
866 }
867 }
868
869 #[test]
872 fn test_default_settings() {
873 let settings = Settings::default();
874 assert_eq!(settings.version, SETTINGS_VERSION);
875 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
876 assert_eq!(settings.theme, "default");
877 assert!(settings.default_model.is_none());
878 assert!(settings.default_provider.is_none());
879 assert!(settings.extensions_enabled);
880 assert!(settings.auto_compaction);
881 assert_eq!(settings.tool_timeout_seconds, 120);
882 assert!(settings.stream_responses);
883 }
884
885 #[test]
886 fn test_merge_cli() {
887 let mut settings = Settings::default();
888 settings.default_model = Some("gpt-4o".to_string());
889
890 settings.merge_cli(Some("claude".to_string()), None);
891 assert_eq!(settings.default_model, Some("claude".to_string()));
892
893 settings.merge_cli(None, Some("google".to_string()));
894 assert_eq!(settings.default_provider, Some("google".to_string()));
895 }
896
897 #[test]
900 fn test_layer_file_overrides() {
901 let base = Settings::default();
902
903 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
904 let toml_content = r#"
905default_model = "openai/gpt-4o"
906theme = "dracula"
907"#;
908 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
909
910 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
911 assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
912 assert_eq!(merged.theme, "dracula");
913 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
915 assert!(merged.extensions_enabled);
916 }
917
918 #[test]
919 fn test_layer_file_preserves_unset() {
920 let mut base = Settings::default();
921 base.default_provider = Some("deepseek".to_string());
922
923 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
924 let toml_content = "theme = \"monokai\"\n";
926 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
927
928 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
929 assert_eq!(merged.theme, "monokai");
930 assert_eq!(merged.default_provider, Some("deepseek".to_string()));
931 }
932
933 #[test]
934 fn test_load_from_dir_with_project_config() {
935 let _guard = EnvGuard::new(&[
936 "OXI_MODEL",
937 "OXI_PROVIDER",
938 "OXI_THEME",
939 "OXI_TOOL_TIMEOUT",
940 "OXI_TEMPERATURE",
941 "OXI_MAX_TOKENS",
942 "OXI_SESSION_DIR",
943 "OXI_STREAM",
944 "OXI_EXTENSIONS_ENABLED",
945 ]);
946 let tmp = tempfile::tempdir().unwrap();
947 let oxi_dir = tmp.path().join(".oxi");
948 fs::create_dir_all(&oxi_dir).unwrap();
949 let settings_path = oxi_dir.join("settings.toml");
950 fs::write(
952 &settings_path,
953 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
954 )
955 .unwrap();
956
957 let settings = Settings::load_from(tmp.path()).unwrap();
958 assert_eq!(settings.default_model, Some("gemini-2.0-flash".to_string()));
960 assert_eq!(settings.default_provider, Some("google".to_string()));
961 }
962
963 #[test]
964 fn test_load_from_dir_no_config() {
965 let _guard = EnvGuard::new(&[
967 "OXI_MODEL",
968 "OXI_PROVIDER",
969 "OXI_THEME",
970 "OXI_TOOL_TIMEOUT",
971 "OXI_TEMPERATURE",
972 "OXI_MAX_TOKENS",
973 "OXI_SESSION_DIR",
974 "OXI_STREAM",
975 "OXI_EXTENSIONS_ENABLED",
976 ]);
977 let tmp = tempfile::tempdir().unwrap();
978 let settings = Settings::load_from(tmp.path()).unwrap();
979 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
981 }
982
983 #[test]
986 fn test_from_env() {
987 let _guard = EnvGuard::new(&[
990 "OXI_MODEL",
992 "OXI_THEME",
993 "OXI_TOOL_TIMEOUT",
994 "OXI_PROVIDER",
995 "OXI_DEFAULT_MODEL",
996 ]);
997
998 let settings = Settings::from_env();
999 assert_eq!(settings.default_model, None);
1001 assert_eq!(settings.theme, "default");
1002 assert_eq!(settings.tool_timeout_seconds, 120);
1003 }
1004
1005 #[test]
1006 fn test_apply_env_boolish() {
1007 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1010 env::set_var("OXI_STREAM", "false");
1011 env::set_var("OXI_EXTENSIONS_ENABLED", "0");
1012
1013 let mut settings = Settings::default();
1014 settings.apply_env();
1015 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1019
1020 #[test]
1021 fn test_apply_env_temperature() {
1022 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1024 env::set_var("OXI_TEMPERATURE", "0.7");
1025
1026 let mut settings = Settings::default();
1027 settings.apply_env();
1028 assert_eq!(settings.default_temperature, None);
1030 }
1031
1032 #[test]
1033 fn test_env_does_not_override_when_unset() {
1034 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1035 let settings = Settings::from_env();
1036 assert!(settings.default_model.is_none());
1037 assert!(settings.default_provider.is_none());
1038 }
1039
1040 #[test]
1043 fn test_parse_thinking_level() {
1044 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1045 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1046 assert_eq!(
1047 parse_thinking_level("MINIMAL"),
1048 Some(ThinkingLevel::Minimal)
1049 );
1050 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1051 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1052 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1053 assert_eq!(
1054 parse_thinking_level("Standard"),
1055 Some(ThinkingLevel::Medium)
1056 );
1057 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1058 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1059 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1060 assert_eq!(parse_thinking_level("invalid"), None);
1061 }
1062
1063 #[test]
1064 fn test_parse_boolish() {
1065 assert!(parse_boolish("true").unwrap());
1066 assert!(parse_boolish("1").unwrap());
1067 assert!(parse_boolish("yes").unwrap());
1068 assert!(parse_boolish("ON").unwrap());
1069 assert!(!parse_boolish("false").unwrap());
1070 assert!(!parse_boolish("0").unwrap());
1071 assert!(!parse_boolish("no").unwrap());
1072 assert!(!parse_boolish("OFF").unwrap());
1073 assert!(parse_boolish("maybe").is_err());
1074 }
1075
1076 #[test]
1079 fn test_effective_model_combines_provider_and_model() {
1080 let mut settings = Settings::default();
1081 settings.default_provider = Some("openai".to_string());
1082 settings.default_model = Some("gpt-4o".to_string());
1083 assert_eq!(
1084 settings.effective_model(None),
1085 Some("openai/gpt-4o".to_string())
1086 );
1087 }
1088
1089 #[test]
1090 fn test_effective_model_cli_overrides() {
1091 let mut settings = Settings::default();
1092 settings.default_provider = Some("openai".to_string());
1093 settings.default_model = Some("gpt-4o".to_string());
1094 assert_eq!(
1095 settings.effective_model(Some("anthropic/claude-3")),
1096 Some("anthropic/claude-3".to_string())
1097 );
1098 }
1099
1100 #[test]
1101 fn test_effective_model_no_provider_returns_bare() {
1102 let mut settings = Settings::default();
1103 settings.default_model = Some("gpt-4o".to_string());
1104 assert_eq!(settings.effective_model(None), Some("gpt-4o".to_string()));
1105 }
1106
1107 #[test]
1108 fn test_effective_model_falls_back_to_last_used() {
1109 let mut settings = Settings::default();
1110 settings.last_used_model = Some("anthropic/claude-3".to_string());
1111 assert_eq!(
1112 settings.effective_model(None),
1113 Some("anthropic/claude-3".to_string())
1114 );
1115 }
1116
1117 #[test]
1118 fn test_effective_temperature_prefers_f64() {
1119 let mut settings = Settings::default();
1120 settings.temperature = Some(0.5);
1121 settings.default_temperature = Some(0.7);
1122 assert_eq!(settings.effective_temperature(), Some(0.7));
1123 }
1124
1125 #[test]
1126 fn test_effective_temperature_falls_back_to_f32() {
1127 let mut settings = Settings::default();
1128 settings.temperature = Some(0.5);
1129 assert_eq!(settings.effective_temperature(), Some(0.5));
1130 }
1131
1132 #[test]
1133 fn test_effective_max_tokens_prefers_usize() {
1134 let mut settings = Settings::default();
1135 settings.max_tokens = Some(1024);
1136 settings.max_response_tokens = Some(4096);
1137 assert_eq!(settings.effective_max_tokens(), Some(4096));
1138 }
1139
1140 #[test]
1141 fn test_effective_max_tokens_falls_back_to_u32() {
1142 let mut settings = Settings::default();
1143 settings.max_tokens = Some(1024);
1144 assert_eq!(settings.effective_max_tokens(), Some(1024));
1145 }
1146
1147 #[test]
1150 fn test_effective_session_dir_default() {
1151 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1152 let settings = Settings::default();
1153 let dir = settings.effective_session_dir().unwrap();
1154 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1155 }
1156
1157 #[test]
1158 fn test_effective_session_dir_from_field() {
1159 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1160 let mut settings = Settings::default();
1161 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1162 assert_eq!(
1163 settings.effective_session_dir().unwrap(),
1164 PathBuf::from("/tmp/oxi-sessions")
1165 );
1166 }
1167
1168 #[test]
1169 fn test_effective_session_dir_env_disabled() {
1170 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1173 env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
1174 let settings = Settings::default();
1175 let dir = settings.effective_session_dir().unwrap();
1177 assert!(
1178 dir.ends_with("sessions"),
1179 "expected default sessions dir, got: {:?}",
1180 dir
1181 );
1182 }
1183
1184 #[test]
1187 fn test_migration_v0_to_v1() {
1188 let mut settings = Settings::default();
1189 settings.version = 0;
1190 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1193 assert_eq!(migrated.version, SETTINGS_VERSION);
1194 assert_eq!(migrated.tool_timeout_seconds, 120);
1195 }
1196
1197 #[test]
1198 fn test_migration_already_current() {
1199 let settings = Settings::default();
1200 let migrated = Settings::migrate(settings).unwrap();
1201 assert_eq!(migrated.version, SETTINGS_VERSION);
1202 }
1203
1204 #[test]
1205 fn test_migration_v3_to_v4_splits_model() {
1206 let mut settings = Settings::default();
1207 settings.version = 3;
1208 settings.default_model = Some("openai/gpt-4o".to_string());
1209 settings.default_provider = None;
1210
1211 let migrated = Settings::migrate(settings).unwrap();
1212 assert_eq!(migrated.version, SETTINGS_VERSION);
1213 assert_eq!(migrated.default_model, Some("gpt-4o".to_string()));
1214 assert_eq!(migrated.default_provider, Some("openai".to_string()));
1215 }
1216
1217 #[test]
1218 fn test_migration_v3_no_slash_keeps_model() {
1219 let mut settings = Settings::default();
1220 settings.version = 3;
1221 settings.default_model = Some("bare-model-name".to_string());
1222
1223 let migrated = Settings::migrate(settings).unwrap();
1224 assert_eq!(migrated.version, SETTINGS_VERSION);
1225 assert_eq!(migrated.default_model, Some("bare-model-name".to_string()));
1226 }
1227
1228 #[test]
1229 fn test_migration_future_version_fails() {
1230 let mut settings = Settings::default();
1231 settings.version = 9999;
1232 assert!(Settings::migrate(settings).is_err());
1233 }
1234
1235 #[test]
1238 fn test_save_and_load_roundtrip() {
1239 let tmp = tempfile::tempdir().unwrap();
1240 let settings_path = tmp.path().join("settings.toml");
1241
1242 let mut original = Settings::default();
1243 original.default_model = Some("gpt-4o".to_string());
1244 original.default_provider = Some("openai".to_string());
1245 original.theme = "dracula".to_string();
1246 original.tool_timeout_seconds = 60;
1247
1248 let content = toml::to_string_pretty(&original).unwrap();
1250 fs::write(&settings_path, &content).unwrap();
1251
1252 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1254 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1255
1256 assert_eq!(loaded.default_model, original.default_model);
1257 assert_eq!(loaded.theme, original.theme);
1258 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1259 }
1260
1261 #[test]
1262 fn test_toml_roundtrip_preserves_new_fields() {
1263 let mut settings = Settings::default();
1264 settings.default_temperature = Some(0.8);
1265 settings.max_response_tokens = Some(8192);
1266 settings.auto_compaction = false;
1267 settings.extensions_enabled = false;
1268 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1269
1270 let toml_str = toml::to_string_pretty(&settings).unwrap();
1271 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1272
1273 assert_eq!(parsed.default_temperature, Some(0.8));
1274 assert_eq!(parsed.max_response_tokens, Some(8192));
1275 assert!(!parsed.auto_compaction);
1276 assert!(!parsed.extensions_enabled);
1277 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1278 }
1279
1280 #[test]
1283 fn test_json_roundtrip() {
1284 let mut settings = Settings::default();
1285 settings.default_model = Some("gpt-4o".to_string());
1286 settings.default_provider = Some("openai".to_string());
1287 settings.theme = "dracula".to_string();
1288 settings.tool_timeout_seconds = 60;
1289 settings.default_temperature = Some(0.8);
1290 settings.max_response_tokens = Some(8192);
1291
1292 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1293 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1294
1295 assert_eq!(parsed.default_model, settings.default_model);
1296 assert_eq!(parsed.theme, settings.theme);
1297 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1298 assert_eq!(parsed.default_temperature, settings.default_temperature);
1299 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1300 }
1301
1302 #[test]
1303 fn test_json_serialize_for_format() {
1304 let mut settings = Settings::default();
1305 settings.default_model = Some("claude-3".to_string());
1306 settings.default_provider = Some("anthropic".to_string());
1307 settings.thinking_level = ThinkingLevel::Minimal;
1308
1309 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1310 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1311
1312 assert_eq!(parsed.default_model, Some("claude-3".to_string()));
1313 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1314 }
1315
1316 #[test]
1317 fn test_toml_serialize_for_format() {
1318 let mut settings = Settings::default();
1319 settings.default_model = Some("gemini-pro".to_string());
1320 settings.default_provider = Some("google".to_string());
1321 settings.thinking_level = ThinkingLevel::High;
1322
1323 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1324 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1325
1326 assert_eq!(parsed.default_model, Some("gemini-pro".to_string()));
1327 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1328 }
1329
1330 #[test]
1331 fn test_parse_from_str_json() {
1332 let json_content = r#"{
1333 "default_model": "gpt-4",
1334 "default_provider": "openai",
1335 "theme": "nord",
1336 "tool_timeout_seconds": 90
1337 }"#;
1338
1339 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1340 assert_eq!(settings.default_model, Some("gpt-4".to_string()));
1341 assert_eq!(settings.default_provider, Some("openai".to_string()));
1342 assert_eq!(settings.theme, "nord");
1343 assert_eq!(settings.tool_timeout_seconds, 90);
1344 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1346 assert!(settings.extensions_enabled);
1347 }
1348
1349 #[test]
1350 fn test_parse_from_str_toml() {
1351 let toml_content = r#"
1352default_model = "claude-opus"
1353default_provider = "anthropic"
1354theme = "monokai"
1355tool_timeout_seconds = 45
1356"#;
1357
1358 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1359 assert_eq!(settings.default_model, Some("claude-opus".to_string()));
1360 assert_eq!(settings.default_provider, Some("anthropic".to_string()));
1361 assert_eq!(settings.theme, "monokai");
1362 assert_eq!(settings.tool_timeout_seconds, 45);
1363 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1364 }
1365
1366 #[test]
1367 fn test_layer_file_json() {
1368 let base = Settings::default();
1369
1370 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1371 let json_content = r#"{
1372 "default_model": "gpt-4o",
1373 "default_provider": "openai",
1374 "theme": "dracula",
1375 "auto_compaction": false
1376 }"#;
1377 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1378
1379 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1380 assert_eq!(merged.default_model, Some("gpt-4o".to_string()));
1381 assert_eq!(merged.default_provider, Some("openai".to_string()));
1382 assert_eq!(merged.theme, "dracula");
1383 assert!(!merged.auto_compaction);
1384 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1386 assert!(merged.extensions_enabled);
1387 assert_eq!(merged.tool_timeout_seconds, 120);
1388 }
1389
1390 #[test]
1391 fn test_layer_file_json_preserves_unset() {
1392 let mut base = Settings::default();
1393 base.default_provider = Some("deepseek".to_string());
1394
1395 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1396 let json_content = r#"{ "theme": "nord" }"#;
1397 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1398
1399 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1400 assert_eq!(merged.theme, "nord");
1401 assert_eq!(merged.default_provider, Some("deepseek".to_string()));
1402 }
1403
1404 #[test]
1405 fn test_save_to_json() {
1406 let tmp = tempfile::tempdir().unwrap();
1407 let settings_path = tmp.path().join("settings.json");
1408
1409 let mut settings = Settings::default();
1410 settings.default_model = Some("gpt-4o".to_string());
1411 settings.default_provider = Some("openai".to_string());
1412 settings.theme = "dracula".to_string();
1413 settings.tool_timeout_seconds = 60;
1414
1415 settings.save_to(&settings_path).unwrap();
1416
1417 let content = fs::read_to_string(&settings_path).unwrap();
1419 let parsed: Settings = serde_json::from_str(&content).unwrap();
1420 assert_eq!(parsed.default_model, Some("gpt-4o".to_string()));
1421 assert_eq!(parsed.theme, "dracula");
1422 assert_eq!(parsed.tool_timeout_seconds, 60);
1423 }
1424
1425 #[test]
1426 fn test_save_to_toml() {
1427 let tmp = tempfile::tempdir().unwrap();
1428 let settings_path = tmp.path().join("settings.toml");
1429
1430 let mut settings = Settings::default();
1431 settings.default_model = Some("gemini-pro".to_string());
1432 settings.default_provider = Some("google".to_string());
1433 settings.theme = "monokai".to_string();
1434 settings.tool_timeout_seconds = 90;
1435
1436 settings.save_to(&settings_path).unwrap();
1437
1438 let content = fs::read_to_string(&settings_path).unwrap();
1440 let parsed: Settings = toml::from_str(&content).unwrap();
1441 assert_eq!(parsed.default_model, Some("gemini-pro".to_string()));
1442 assert_eq!(parsed.theme, "monokai");
1443 assert_eq!(parsed.tool_timeout_seconds, 90);
1444 }
1445
1446 #[test]
1447 fn test_load_from_dir_with_json_project_config() {
1448 let _guard = EnvGuard::new(&[
1449 "OXI_MODEL",
1450 "OXI_PROVIDER",
1451 "OXI_THEME",
1452 "OXI_TOOL_TIMEOUT",
1453 "OXI_TEMPERATURE",
1454 "OXI_MAX_TOKENS",
1455 "OXI_SESSION_DIR",
1456 "OXI_STREAM",
1457 "OXI_EXTENSIONS_ENABLED",
1458 ]);
1459 let tmp = tempfile::tempdir().unwrap();
1460 let oxi_dir = tmp.path().join(".oxi");
1461 fs::create_dir_all(&oxi_dir).unwrap();
1462 let settings_path = oxi_dir.join("settings.json");
1463 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1465 fs::write(&settings_path, json_content).unwrap();
1466
1467 let settings = Settings::load_from(tmp.path()).unwrap();
1468 assert_eq!(settings.default_model, Some("gemini-2.0-flash".to_string()));
1470 assert_eq!(settings.default_provider, Some("google".to_string()));
1471 }
1472
1473 #[test]
1474 fn test_find_project_settings_json_priority() {
1475 let tmp = tempfile::tempdir().unwrap();
1476 let oxi_dir = tmp.path().join(".oxi");
1477 fs::create_dir_all(&oxi_dir).unwrap();
1478
1479 let json_path = oxi_dir.join("settings.json");
1481 let toml_path = oxi_dir.join("settings.toml");
1482 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1483 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1484
1485 let found = Settings::find_project_settings(tmp.path());
1487 assert!(found.is_some());
1488 assert_eq!(
1489 found.unwrap().file_name().unwrap().to_str().unwrap(),
1490 "settings.json"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_find_project_settings_json_only() {
1496 let tmp = tempfile::tempdir().unwrap();
1497 let oxi_dir = tmp.path().join(".oxi");
1498 fs::create_dir_all(&oxi_dir).unwrap();
1499
1500 let json_path = oxi_dir.join("settings.json");
1501 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1502
1503 let found = Settings::find_project_settings(tmp.path());
1504 assert!(found.is_some());
1505 assert_eq!(
1506 found.unwrap().file_name().unwrap().to_str().unwrap(),
1507 "settings.json"
1508 );
1509 }
1510
1511 #[test]
1512 fn test_find_project_settings_toml_fallback() {
1513 let tmp = tempfile::tempdir().unwrap();
1514 let oxi_dir = tmp.path().join(".oxi");
1515 fs::create_dir_all(&oxi_dir).unwrap();
1516
1517 let toml_path = oxi_dir.join("settings.toml");
1518 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1519
1520 let found = Settings::find_project_settings(tmp.path());
1521 assert!(found.is_some());
1522 assert_eq!(
1523 found.unwrap().file_name().unwrap().to_str().unwrap(),
1524 "settings.toml"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_detect_format() {
1530 let json_path = PathBuf::from("/test/settings.json");
1531 let toml_path = PathBuf::from("/test/settings.toml");
1532 let unknown_path = PathBuf::from("/test/settings");
1533
1534 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1535 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1536 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1537 }
1539
1540 #[test]
1541 fn test_settings_format_extension() {
1542 assert_eq!(SettingsFormat::Json.extension(), "json");
1543 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1544 }
1545
1546 #[test]
1547 fn test_layer_json_over_toml() {
1548 let tmp = tempfile::tempdir().unwrap();
1550 let oxi_dir = tmp.path().join(".oxi");
1551 fs::create_dir_all(&oxi_dir).unwrap();
1552
1553 let json_path = oxi_dir.join("settings.json");
1554 let toml_path = oxi_dir.join("settings.toml");
1555
1556 fs::write(&json_path, r#"{ "default_model": "json-model" }"#).unwrap();
1558 fs::write(&toml_path, r#"default_model = "toml-model""#).unwrap();
1560
1561 let settings = Settings::load_from(tmp.path()).unwrap();
1563 assert_eq!(settings.default_model, Some("json-model".to_string()));
1564 }
1565
1566 #[test]
1567 fn test_mixed_format_loading() {
1568 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1570 let toml_content = r#"
1571default_model = "loaded-via-toml"
1572theme = "loaded-theme"
1573stream_responses = false
1574"#;
1575 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1576
1577 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1578 assert_eq!(merged.default_model, Some("loaded-via-toml".to_string()));
1579 assert_eq!(merged.theme, "loaded-theme");
1580 assert!(!merged.stream_responses);
1581 }
1582
1583 #[test]
1584 fn test_merge_json_values() {
1585 let base = serde_json::json!({
1586 "version": 1,
1587 "theme": "default",
1588 "extensions": ["ext1"],
1589 "nested": {
1590 "a": 1,
1591 "b": 2
1592 }
1593 });
1594
1595 let override_ = serde_json::json!({
1596 "version": 2,
1597 "theme": "dark",
1598 "extensions": ["ext2"],
1599 "nested": {
1600 "b": 20,
1601 "c": 30
1602 }
1603 });
1604
1605 let merged = merge_json_values(base, override_);
1606
1607 assert_eq!(merged["version"], 2);
1608 assert_eq!(merged["theme"], "dark");
1609 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1611 assert_eq!(merged["nested"]["a"], 1);
1613 assert_eq!(merged["nested"]["b"], 20);
1614 assert_eq!(merged["nested"]["c"], 30);
1615 }
1616
1617 #[test]
1618 fn test_save_project_preserves_existing_format() {
1619 let tmp = tempfile::tempdir().unwrap();
1620 let oxi_dir = tmp.path().join(".oxi");
1621 fs::create_dir_all(&oxi_dir).unwrap();
1622
1623 let toml_path = oxi_dir.join("settings.toml");
1625 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1626
1627 let mut settings = Settings::default();
1628 settings.theme = "new-theme".to_string();
1629 settings.save_project(tmp.path()).unwrap();
1630
1631 let content = fs::read_to_string(&toml_path).unwrap();
1633 assert!(content.contains("new-theme"));
1634 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1635 }
1636
1637 #[test]
1638 fn test_save_project_creates_json_by_default() {
1639 let tmp = tempfile::tempdir().unwrap();
1640 let oxi_dir = tmp.path().join(".oxi");
1641 fs::create_dir_all(&oxi_dir).unwrap();
1642 let mut settings = Settings::default();
1645 settings.theme = "json-theme".to_string();
1646 settings.save_project(tmp.path()).unwrap();
1647
1648 let json_path = oxi_dir.join("settings.json");
1650 assert!(json_path.exists());
1651 let content = fs::read_to_string(&json_path).unwrap();
1652 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1653 assert!(content.contains("json-theme"));
1654 }
1655
1656 #[test]
1659 fn test_custom_provider_default_api() {
1660 use super::CustomProvider;
1661 let cp = CustomProvider {
1662 name: "test".to_string(),
1663 base_url: "https://api.test.com/v1".to_string(),
1664 api_key_env: "TEST_API_KEY".to_string(),
1665 api: super::default_custom_provider_api(),
1666 };
1667 assert_eq!(cp.api, "openai-completions");
1668 }
1669
1670 #[test]
1671 fn test_custom_provider_toml_deserialize() {
1672 let toml_content = r#"
1673[[custom_providers]]
1674name = "minimax"
1675base_url = "https://api.minimax.chat/v1"
1676api_key_env = "MINIMAX_API_KEY"
1677api = "openai-completions"
1678
1679[[custom_providers]]
1680name = "zai"
1681base_url = "https://api.z.ai/v1"
1682api_key_env = "ZAI_API_KEY"
1683api = "openai-responses"
1684"#;
1685 let settings: Settings = toml::from_str(toml_content).unwrap();
1686 assert_eq!(settings.custom_providers.len(), 2);
1687 assert_eq!(settings.custom_providers[0].name, "minimax");
1688 assert_eq!(
1689 settings.custom_providers[0].base_url,
1690 "https://api.minimax.chat/v1"
1691 );
1692 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
1693 assert_eq!(settings.custom_providers[0].api, "openai-completions");
1694 assert_eq!(settings.custom_providers[1].name, "zai");
1695 assert_eq!(settings.custom_providers[1].api, "openai-responses");
1696 }
1697
1698 #[test]
1699 fn test_custom_provider_json_deserialize() {
1700 let json_content = r#"{
1701 "custom_providers": [
1702 {
1703 "name": "minimax",
1704 "base_url": "https://api.minimax.chat/v1",
1705 "api_key_env": "MINIMAX_API_KEY",
1706 "api": "openai-completions"
1707 }
1708 ]
1709 }"#;
1710 let settings: Settings = serde_json::from_str(json_content).unwrap();
1711 assert_eq!(settings.custom_providers.len(), 1);
1712 assert_eq!(settings.custom_providers[0].name, "minimax");
1713 }
1714
1715 #[test]
1716 fn test_custom_provider_toml_roundtrip() {
1717 let mut settings = Settings::default();
1718 settings.custom_providers.push(super::CustomProvider {
1719 name: "test".to_string(),
1720 base_url: "https://api.test.com/v1".to_string(),
1721 api_key_env: "TEST_API_KEY".to_string(),
1722 api: "openai-completions".to_string(),
1723 });
1724
1725 let toml_str = toml::to_string_pretty(&settings).unwrap();
1726 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1727 assert_eq!(parsed.custom_providers.len(), 1);
1728 assert_eq!(parsed.custom_providers[0].name, "test");
1729 assert_eq!(
1730 parsed.custom_providers[0].base_url,
1731 "https://api.test.com/v1"
1732 );
1733 }
1734
1735 #[test]
1736 fn test_custom_provider_defaults_empty() {
1737 let settings = Settings::default();
1738 assert!(settings.custom_providers.is_empty());
1739 }
1740
1741 #[test]
1742 fn test_custom_provider_layer_file() {
1743 let base = Settings::default();
1744
1745 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1746 let toml_content = r#"
1747[[custom_providers]]
1748name = "my-provider"
1749base_url = "https://api.my-provider.com/v1"
1750api_key_env = "MY_PROVIDER_API_KEY"
1751"#;
1752 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1753
1754 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1755 assert_eq!(merged.custom_providers.len(), 1);
1756 assert_eq!(merged.custom_providers[0].name, "my-provider");
1757 assert_eq!(merged.custom_providers[0].api, "openai-completions");
1759 }
1760}