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 = 5;
25
26pub const KNOWN_CHANNELS: &[(&str, &str)] = &[
37 ("response", "Your conversational responses to the user"),
38 (
39 "code_comment",
40 "Code comments you write (//, /* */, #, etc.)",
41 ),
42 (
43 "documentation",
44 "Documentation (markdown files, README, AGENTS.md, doc comments)",
45 ),
46 ("commit_message", "Git commit messages (subject + body)"),
47];
48
49pub const KNOWN_LANGS: &[(&str, &str)] = &[
60 ("auto", "Auto (match user)"),
61 ("en", "English"),
62 ("ko", "Korean (한국어)"),
63 ("ja", "Japanese (日本語)"),
64 ("zh", "Chinese (中文)"),
65 ("es", "Spanish"),
66 ("fr", "French"),
67 ("de", "German"),
68];
69
70#[allow(dead_code)]
73const ENV_PREFIX: &str = "OXI_";
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
77#[serde(rename_all = "snake_case")]
78pub enum ThinkingLevel {
79 #[default]
81 Off,
82 Minimal,
84 Low,
86 Medium,
88 High,
90 XHigh,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CustomProvider {
100 pub name: String,
102 pub base_url: String,
104 pub api_key_env: String,
106 #[serde(default = "default_custom_provider_api")]
108 pub api: String,
109}
110
111fn default_custom_provider_api() -> String {
112 "openai-completions".to_string()
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Settings {
118 #[serde(default)]
121 pub version: u32,
122
123 #[serde(default = "default_thinking_level")]
126 pub thinking_level: ThinkingLevel,
127
128 #[serde(default = "default_theme")]
130 pub theme: String,
131
132 #[serde(default, skip_serializing)]
134 pub default_model: Option<String>,
135
136 #[serde(default, skip_serializing)]
138 pub default_provider: Option<String>,
139
140 #[serde(default)]
143 pub last_used_model: Option<String>,
144
145 #[serde(default)]
147 pub last_used_provider: Option<String>,
148
149 pub max_tokens: Option<u32>,
151
152 pub temperature: Option<f32>,
154
155 pub default_temperature: Option<f64>,
157
158 pub max_response_tokens: Option<usize>,
160
161 #[serde(default = "default_session_history_size")]
164 pub session_history_size: usize,
165
166 pub session_dir: Option<PathBuf>,
168
169 #[serde(default = "default_true")]
172 pub stream_responses: bool,
173
174 #[serde(default = "default_true")]
176 pub extensions_enabled: bool,
177
178 #[serde(default = "default_true")]
180 pub auto_compaction: bool,
181
182 #[serde(default)]
185 pub disabled_tools: Vec<String>,
186
187 #[serde(default = "default_tool_timeout")]
190 pub tool_timeout_seconds: u64,
191
192 #[serde(default)]
195 pub extensions: Vec<String>,
196
197 #[serde(default)]
199 pub skills: Vec<String>,
200
201 #[serde(default)]
203 pub prompts: Vec<String>,
204
205 #[serde(default)]
207 pub themes: Vec<String>,
208
209 #[serde(default)]
212 pub custom_providers: Vec<CustomProvider>,
213
214 #[serde(default)]
219 pub dynamic_models: HashMap<String, Vec<String>>,
220
221 #[serde(default = "default_false")]
224 pub enable_routing: bool,
225
226 #[serde(default)]
228 pub router_profile: Option<String>,
229
230 #[serde(default = "default_true")]
232 pub prefer_cost_efficient: bool,
233
234 #[serde(default)]
236 pub fallback_chain: Vec<String>,
237
238 #[serde(default = "default_true")]
240 pub enable_fallback: bool,
241
242 #[serde(default)]
244 pub disable_fallback: bool,
245
246 #[serde(default = "default_circuit_failure_threshold")]
248 pub circuit_breaker_failure_threshold: u32,
249
250 #[serde(default = "default_circuit_open_duration_secs")]
252 pub circuit_breaker_open_duration_secs: u64,
253
254 #[serde(default)]
259 pub keybindings: HashMap<String, Vec<String>>,
260
261 #[serde(default)]
307 pub output_languages: HashMap<String, String>,
308}
309
310fn default_theme() -> String {
311 "default".to_string()
312}
313
314fn default_thinking_level() -> ThinkingLevel {
315 ThinkingLevel::Medium
316}
317
318fn default_session_history_size() -> usize {
319 100
320}
321
322fn default_true() -> bool {
323 true
324}
325
326fn default_false() -> bool {
327 false
328}
329
330fn default_circuit_failure_threshold() -> u32 {
331 5
332}
333
334fn default_circuit_open_duration_secs() -> u64 {
335 30
336}
337
338fn default_tool_timeout() -> u64 {
339 120
340}
341
342impl Default for Settings {
343 fn default() -> Self {
344 Self {
345 version: SETTINGS_VERSION,
346 thinking_level: ThinkingLevel::Medium,
347 theme: default_theme(),
348 last_used_model: None,
349 last_used_provider: None,
350 default_model: None,
351 default_provider: None,
352 max_tokens: None,
353 temperature: None,
354 default_temperature: None,
355 max_response_tokens: None,
356 session_history_size: default_session_history_size(),
357 session_dir: None,
358 stream_responses: true,
359 extensions_enabled: true,
360 auto_compaction: true,
361 disabled_tools: Vec::new(),
362 tool_timeout_seconds: default_tool_timeout(),
363 extensions: Vec::new(),
364 skills: Vec::new(),
365 prompts: Vec::new(),
366 themes: Vec::new(),
367 custom_providers: Vec::new(),
368 dynamic_models: HashMap::new(),
369 enable_routing: false,
371 router_profile: None,
372 prefer_cost_efficient: true,
373 fallback_chain: Vec::new(),
374 enable_fallback: true,
375 disable_fallback: false,
376 circuit_breaker_failure_threshold: 5,
377 circuit_breaker_open_duration_secs: 30,
378 keybindings: HashMap::new(),
379 output_languages: HashMap::new(),
380 }
381 }
382}
383
384impl Settings {
385 pub fn settings_dir() -> Result<PathBuf> {
389 let base = dirs::home_dir().context("Cannot determine home directory")?;
390 Ok(base.join(".oxi"))
391 }
392
393 pub fn settings_toml_path() -> Result<PathBuf> {
395 Ok(Self::settings_dir()?.join("settings.toml"))
396 }
397
398 pub fn settings_json_path() -> Result<PathBuf> {
400 Ok(Self::settings_dir()?.join("settings.json"))
401 }
402
403 pub fn settings_path() -> Result<PathBuf> {
410 let json_path = Self::settings_json_path()?;
411 let toml_path = Self::settings_toml_path()?;
412
413 if json_path.exists() && toml_path.exists() {
414 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
416 return Ok(json_path);
417 }
418
419 if json_path.exists() {
420 return Ok(json_path);
421 }
422
423 if toml_path.exists() {
424 return Ok(toml_path);
425 }
426
427 Ok(json_path)
429 }
430
431 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
436 let json_path = Self::settings_json_path()?;
437 let toml_path = Self::settings_toml_path()?;
438
439 let (primary, secondary) = if prefer_json {
440 (&json_path, &toml_path)
441 } else {
442 (&toml_path, &json_path)
443 };
444
445 if primary.exists() {
446 return Ok(primary.clone());
447 }
448
449 if secondary.exists() {
450 return Ok(secondary.clone());
451 }
452
453 Ok(primary.clone())
455 }
456
457 pub fn detect_format(path: &Path) -> SettingsFormat {
459 match path.extension().and_then(|e| e.to_str()) {
460 Some("json") => SettingsFormat::Json,
461 Some("toml") => SettingsFormat::Toml,
462 _ => SettingsFormat::Json, }
464 }
465
466 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
471 let mut dir = start_dir.to_path_buf();
472 loop {
473 let json_candidate = dir.join(".oxi").join("settings.json");
475 if json_candidate.exists() {
476 return Some(json_candidate);
477 }
478
479 let toml_candidate = dir.join(".oxi").join("settings.toml");
480 if toml_candidate.exists() {
481 return Some(toml_candidate);
482 }
483
484 if !dir.pop() {
485 return None;
486 }
487 }
488 }
489
490 pub fn effective_session_dir(&self) -> Result<PathBuf> {
494 if let Some(ref dir) = self.session_dir {
495 return Ok(dir.clone());
496 }
497 Ok(Self::settings_dir()?.join("sessions"))
498 }
499
500 pub fn load() -> Result<Self> {
518 Self::load_from_cwd()
519 }
520
521 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
523 let mut settings = Settings::default();
525
526 if let Ok(global_path) = Self::settings_path()
528 && global_path.exists()
529 {
530 settings = Self::layer_file(&settings, &global_path)?;
531 }
532
533 if let Some(project_path) = Self::find_project_settings(dir) {
535 settings = Self::layer_file(&settings, &project_path)?;
536 }
537
538 settings.apply_env();
540
541 settings = Self::migrate(settings)?;
543
544 settings.validate_output_languages();
546
547 Ok(settings)
548 }
549
550 fn validate_output_languages(&mut self) {
557 if self.output_languages.is_empty() {
558 return;
559 }
560 let known_langs: std::collections::HashSet<&str> =
561 KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
562
563 for (channel, lang) in &self.output_languages {
564 if !known_langs.contains(lang.as_str()) {
565 tracing::warn!(
566 "Unknown output_languages language code '{}' for channel '{}'. \
567 Keeping as-is (the model will likely understand).",
568 lang,
569 channel
570 );
571 }
572 }
573 }
574
575 pub fn load_from_cwd() -> Result<Self> {
577 let cwd = env::current_dir().context("Cannot determine current directory")?;
578 Self::load_from(&cwd)
579 }
580
581 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
587 let content = fs::read_to_string(path)
588 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
589
590 let format = Self::detect_format(path);
591 let overlay: serde_json::Value = match format {
592 SettingsFormat::Toml => {
593 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
594 format!("Failed to parse TOML settings from {}", path.display())
595 })?;
596 toml_value_to_json(toml_value)
598 }
599 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
600 format!("Failed to parse JSON settings from {}", path.display())
601 })?,
602 };
603
604 let base_json =
608 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
609
610 let merged = merge_json_values(base_json, overlay);
611 let result: Settings =
612 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
613
614 Ok(result)
615 }
616
617 #[allow(dead_code)]
643 pub fn apply_env(&mut self) {
644 }
648
649 #[allow(dead_code)]
655 pub fn from_env() -> Self {
656 Self::default()
657 }
658
659 pub fn save(&self) -> Result<()> {
666 let dir = Self::settings_dir()?;
667 let path = Self::settings_path()?;
668
669 if !dir.exists() {
670 fs::create_dir_all(&dir).with_context(|| {
671 format!("Failed to create settings directory {}", dir.display())
672 })?;
673 }
674
675 let format = Self::detect_format(&path);
676 let content = Self::serialize_for_format(self, format)?;
677
678 let tmp_path = path.with_extension("tmp");
680 fs::write(&tmp_path, &content)
681 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
682 fs::rename(&tmp_path, &path)
683 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
684
685 Ok(())
686 }
687
688 pub fn save_to(&self, path: &Path) -> Result<()> {
690 if let Some(parent) = path.parent()
691 && !parent.exists()
692 {
693 fs::create_dir_all(parent)
694 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
695 }
696
697 let format = Self::detect_format(path);
698 let content = Self::serialize_for_format(self, format)?;
699
700 let tmp_path = path.with_extension("tmp");
702 fs::write(&tmp_path, &content)
703 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
704 fs::rename(&tmp_path, path)
705 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
706
707 Ok(())
708 }
709
710 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
714 let dir = project_dir.join(".oxi");
715
716 if !dir.exists() {
717 fs::create_dir_all(&dir).with_context(|| {
718 format!(
719 "Failed to create project settings directory {}",
720 dir.display()
721 )
722 })?;
723 }
724
725 let json_path = dir.join("settings.json");
727 let toml_path = dir.join("settings.toml");
728
729 let path = if json_path.exists() {
730 &json_path
731 } else if toml_path.exists() {
732 &toml_path
733 } else {
734 &json_path
736 };
737
738 let format = Self::detect_format(path);
739 let content = Self::serialize_for_format(self, format)?;
740
741 let tmp_path = path.with_extension("tmp");
743 fs::write(&tmp_path, &content)
744 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
745 fs::rename(&tmp_path, path)
746 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
747
748 Ok(())
749 }
750
751 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
753 match format {
754 SettingsFormat::Toml => {
755 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
756 }
757 SettingsFormat::Json => serde_json::to_string_pretty(settings)
758 .context("Failed to serialize settings to JSON"),
759 }
760 }
761
762 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
764 match format {
765 SettingsFormat::Toml => {
766 toml::from_str(content).context("Failed to parse TOML settings")
767 }
768 SettingsFormat::Json => {
769 serde_json::from_str(content).context("Failed to parse JSON settings")
770 }
771 }
772 }
773
774 pub fn merge_cli(
787 &mut self,
788 model: Option<String>,
789 provider: Option<String>,
790 enable_routing: Option<bool>,
791 prefer_cost_efficient: Option<bool>,
792 fallback_chain: Option<Vec<String>>,
793 disable_fallback: Option<bool>,
794 ) {
795 if let Some(m) = model {
796 self.last_used_model = Some(m);
797 }
798 if let Some(p) = provider {
799 self.last_used_provider = Some(p);
800 }
801 if let Some(r) = enable_routing {
802 self.enable_routing = r;
803 }
804 if let Some(p) = prefer_cost_efficient {
805 self.prefer_cost_efficient = p;
806 }
807 if let Some(fc) = fallback_chain
808 && !fc.is_empty()
809 {
810 self.fallback_chain = fc;
811 }
812 if let Some(df) = disable_fallback {
813 self.disable_fallback = df;
814 if df {
816 self.enable_fallback = false;
817 }
818 }
819 }
820
821 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
824 cli_model.map(String::from).or_else(|| {
825 let model = self.last_used_model.as_ref()?;
830 if model.contains('/') {
831 Some(model.clone())
833 } else if let Some(ref provider) = self.last_used_provider {
834 Some(format!("{}/{}", provider, model))
836 } else {
837 Some(model.clone())
838 }
839 })
840 }
841
842 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
845 cli_provider
846 .map(String::from)
847 .or_else(|| self.last_used_provider.clone())
848 }
849
850 pub fn effective_temperature(&self) -> Option<f64> {
853 self.default_temperature
854 .or(self.temperature.map(|t| t as f64))
855 }
856
857 pub fn effective_max_tokens(&self) -> Option<usize> {
860 self.max_response_tokens
861 .or(self.max_tokens.map(|t| t as usize))
862 }
863
864 pub fn router_profile(&self) -> Option<&str> {
866 self.router_profile.as_deref()
867 }
868
869 pub fn save_last_used(model_id: &str) {
875 if let Ok(mut settings) = Self::load() {
876 if let Some((provider, model)) = model_id.split_once('/') {
877 settings.last_used_provider = Some(provider.to_string());
878 settings.last_used_model = Some(model.to_string());
879 } else {
880 settings.last_used_model = Some(model_id.to_string());
881 }
882 let _ = settings.save();
883 }
884 }
885
886 pub fn save_theme(&mut self, name: &str) -> Result<()> {
888 self.theme = name.to_string();
889 self.save()
890 }
891
892 pub fn get_theme_name(&self) -> String {
894 if self.theme.is_empty() || self.theme == "default" {
895 "oxi_dark".to_string()
896 } else {
897 self.theme.clone()
898 }
899 }
900
901 fn migrate(settings: Settings) -> Result<Settings> {
912 let mut settings = settings;
913
914 match settings.version {
915 SETTINGS_VERSION => {
916 }
918 0 => {
919 if settings.tool_timeout_seconds == 0 {
922 settings.tool_timeout_seconds = default_tool_timeout();
923 }
924 settings.version = SETTINGS_VERSION;
925
926 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
927 }
928 1 | 2 => {
929 settings.version = SETTINGS_VERSION;
932 tracing::info!(
933 "Migrated settings from version {} to {}",
934 settings.version,
935 SETTINGS_VERSION
936 );
937 }
938 3 => {
939 if let Some(model) = settings.default_model.take() {
941 if let Some((provider, model_name)) = model.split_once('/') {
942 settings.last_used_provider = Some(provider.to_string());
943 settings.last_used_model = Some(model_name.to_string());
944 } else {
945 settings.last_used_model = Some(model);
946 }
947 }
948 settings.version = SETTINGS_VERSION;
949 tracing::info!(
950 "Migrated settings from version 3 to {} (default_model → last_used_model)",
951 SETTINGS_VERSION
952 );
953 }
954 4 => {
955 settings.version = SETTINGS_VERSION;
960 tracing::info!(
961 "Migrated settings from version 4 to {} (added output_languages, defaulting all channels to auto)",
962 SETTINGS_VERSION
963 );
964 }
965 v if v > SETTINGS_VERSION => {
966 anyhow::bail!(
968 "Settings version {} is newer than supported version {}. \
969 Please update oxi.",
970 v,
971 SETTINGS_VERSION
972 );
973 }
974 v => {
975 tracing::warn!(
977 "Unknown settings version {}, attempting migration to {}",
978 v,
979 SETTINGS_VERSION
980 );
981 settings.version = SETTINGS_VERSION;
982 }
983 }
984
985 Ok(settings)
986 }
987}
988
989#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
993pub enum SettingsFormat {
994 #[default]
996 Json,
997 Toml,
999}
1000
1001impl SettingsFormat {
1002 pub fn extension(&self) -> &'static str {
1004 match self {
1005 SettingsFormat::Json => "json",
1006 SettingsFormat::Toml => "toml",
1007 }
1008 }
1009}
1010
1011fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1015 match toml {
1016 toml::Value::String(s) => serde_json::Value::String(s),
1017 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1018 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1019 .map(serde_json::Value::Number)
1020 .unwrap_or(serde_json::Value::Null),
1021 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1022 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1023 toml::Value::Array(arr) => {
1024 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1025 }
1026 toml::Value::Table(table) => {
1027 let obj = table
1028 .into_iter()
1029 .map(|(k, v)| (k, toml_value_to_json(v)))
1030 .collect();
1031 serde_json::Value::Object(obj)
1032 }
1033 }
1034}
1035
1036fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1038 match (base, override_) {
1039 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1041 let mut result = base_map;
1042 for (key, override_value) in override_map {
1043 let base_value = result.remove(&key);
1044 let merged = match base_value {
1045 Some(base_v) => merge_json_values(base_v, override_value),
1046 None => override_value,
1047 };
1048 result.insert(key, merged);
1049 }
1050 serde_json::Value::Object(result)
1051 }
1052 (_, override_) => override_,
1054 }
1055}
1056
1057pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1059 match s.to_lowercase().as_str() {
1060 "off" | "none" => Some(ThinkingLevel::Off),
1061 "minimal" => Some(ThinkingLevel::Minimal),
1062 "low" => Some(ThinkingLevel::Low),
1063 "medium" | "standard" => Some(ThinkingLevel::Medium),
1064 "high" | "thorough" => Some(ThinkingLevel::High),
1065 "xhigh" => Some(ThinkingLevel::XHigh),
1066 _ => None,
1067 }
1068}
1069
1070#[allow(dead_code)]
1072fn parse_boolish(s: &str) -> Result<bool> {
1073 match s.to_lowercase().as_str() {
1074 "true" | "1" | "yes" | "on" => Ok(true),
1075 "false" | "0" | "no" | "off" => Ok(false),
1076 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1077 }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082 use super::*;
1083 use std::io::Write as IoWrite;
1084 use std::sync::Mutex;
1085
1086 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1089
1090 struct EnvGuard {
1093 saved: Vec<(String, Option<String>)>,
1094 }
1095
1096 impl EnvGuard {
1097 fn new(vars: &[&str]) -> Self {
1098 let saved = vars
1099 .iter()
1100 .map(|&name| {
1101 let old = env::var(name).ok();
1102 unsafe { env::remove_var(name) };
1104 (name.to_string(), old)
1105 })
1106 .collect();
1107 Self { saved }
1108 }
1109 }
1110
1111 impl Drop for EnvGuard {
1112 fn drop(&mut self) {
1113 for (name, old) in self.saved.drain(..) {
1114 match old {
1115 Some(val) => unsafe { env::set_var(&name, val) },
1117 None => unsafe { env::remove_var(&name) },
1118 }
1119 }
1120 }
1121 }
1122
1123 #[test]
1126 fn test_default_settings() {
1127 let settings = Settings::default();
1128 assert_eq!(settings.version, SETTINGS_VERSION);
1129 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1130 assert_eq!(settings.theme, "default");
1131 assert!(settings.last_used_model.is_none());
1132 assert!(settings.last_used_provider.is_none());
1133 assert!(settings.extensions_enabled);
1134 assert!(settings.auto_compaction);
1135 assert_eq!(settings.tool_timeout_seconds, 120);
1136 assert!(settings.stream_responses);
1137 }
1138
1139 #[test]
1140 fn test_merge_cli() {
1141 let mut settings = Settings::default();
1142 settings.last_used_model = Some("gpt-4o".to_string());
1143
1144 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1145 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1146
1147 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1148 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1149
1150 settings.merge_cli(
1152 None,
1153 None,
1154 Some(true),
1155 Some(false),
1156 Some(vec!["openai/gpt-4o".to_string()]),
1157 Some(false),
1158 );
1159 assert!(settings.enable_routing);
1160 assert!(!settings.prefer_cost_efficient);
1161 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1162 assert!(!settings.disable_fallback);
1163
1164 let mut settings2 = Settings::default();
1166 settings2.merge_cli(None, None, None, None, None, Some(true));
1167 assert!(settings2.disable_fallback);
1168 assert!(!settings2.enable_fallback);
1169 }
1170
1171 #[test]
1174 fn test_layer_file_overrides() {
1175 let base = Settings::default();
1176
1177 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1178 let toml_content = r#"
1179last_used_model = "openai/gpt-4o"
1180theme = "dracula"
1181"#;
1182 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1183
1184 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1185 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1186 assert_eq!(merged.theme, "dracula");
1187 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1189 assert!(merged.extensions_enabled);
1190 }
1191
1192 #[test]
1193 fn test_layer_file_preserves_unset() {
1194 let mut base = Settings::default();
1195 base.last_used_provider = Some("deepseek".to_string());
1196
1197 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1198 let toml_content = "theme = \"monokai\"\n";
1200 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1201
1202 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1203 assert_eq!(merged.theme, "monokai");
1204 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1205 }
1206
1207 #[test]
1208 fn test_load_from_dir_with_project_config() {
1209 let _guard = EnvGuard::new(&[
1210 "OXI_MODEL",
1211 "OXI_PROVIDER",
1212 "OXI_THEME",
1213 "OXI_TOOL_TIMEOUT",
1214 "OXI_TEMPERATURE",
1215 "OXI_MAX_TOKENS",
1216 "OXI_SESSION_DIR",
1217 "OXI_STREAM",
1218 "OXI_EXTENSIONS_ENABLED",
1219 ]);
1220 let tmp = tempfile::tempdir().unwrap();
1221 let oxi_dir = tmp.path().join(".oxi");
1222 fs::create_dir_all(&oxi_dir).unwrap();
1223 let settings_path = oxi_dir.join("settings.toml");
1224 fs::write(
1226 &settings_path,
1227 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1228 )
1229 .unwrap();
1230
1231 let settings = Settings::load_from(tmp.path()).unwrap();
1232 assert_eq!(
1234 settings.last_used_model,
1235 Some("gemini-2.0-flash".to_string())
1236 );
1237 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1238 }
1239
1240 #[test]
1241 fn test_load_from_dir_no_config() {
1242 let _guard = EnvGuard::new(&[
1244 "OXI_MODEL",
1245 "OXI_PROVIDER",
1246 "OXI_THEME",
1247 "OXI_TOOL_TIMEOUT",
1248 "OXI_TEMPERATURE",
1249 "OXI_MAX_TOKENS",
1250 "OXI_SESSION_DIR",
1251 "OXI_STREAM",
1252 "OXI_EXTENSIONS_ENABLED",
1253 ]);
1254 let tmp = tempfile::tempdir().unwrap();
1255 let settings = Settings::load_from(tmp.path()).unwrap();
1256 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1258 }
1259
1260 #[test]
1263 fn test_from_env() {
1264 let _guard = EnvGuard::new(&[
1267 "OXI_MODEL",
1269 "OXI_THEME",
1270 "OXI_TOOL_TIMEOUT",
1271 "OXI_PROVIDER",
1272 "OXI_DEFAULT_MODEL",
1273 ]);
1274
1275 let settings = Settings::from_env();
1276 assert_eq!(settings.last_used_model, None);
1278 assert_eq!(settings.theme, "default");
1279 assert_eq!(settings.tool_timeout_seconds, 120);
1280 }
1281
1282 #[test]
1283 fn test_apply_env_boolish() {
1284 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1287 unsafe { env::set_var("OXI_STREAM", "false") };
1288 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1289
1290 let mut settings = Settings::default();
1291 settings.apply_env();
1292 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1296
1297 #[test]
1298 fn test_apply_env_temperature() {
1299 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1301 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1302
1303 let mut settings = Settings::default();
1304 settings.apply_env();
1305 assert_eq!(settings.default_temperature, None);
1307 }
1308
1309 #[test]
1310 fn test_env_does_not_override_when_unset() {
1311 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1312 let settings = Settings::from_env();
1313 assert!(settings.last_used_model.is_none());
1314 assert!(settings.last_used_provider.is_none());
1315 }
1316
1317 #[test]
1318 fn test_parse_thinking_level() {
1319 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1320 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1321 assert_eq!(
1322 parse_thinking_level("MINIMAL"),
1323 Some(ThinkingLevel::Minimal)
1324 );
1325 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1326 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1327 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1328 assert_eq!(
1329 parse_thinking_level("Standard"),
1330 Some(ThinkingLevel::Medium)
1331 );
1332 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1333 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1334 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1335 assert_eq!(parse_thinking_level("invalid"), None);
1336 }
1337
1338 #[test]
1339 fn test_parse_boolish() {
1340 assert!(parse_boolish("true").unwrap());
1341 assert!(parse_boolish("1").unwrap());
1342 assert!(parse_boolish("yes").unwrap());
1343 assert!(parse_boolish("ON").unwrap());
1344 assert!(!parse_boolish("false").unwrap());
1345 assert!(!parse_boolish("0").unwrap());
1346 assert!(!parse_boolish("no").unwrap());
1347 assert!(!parse_boolish("OFF").unwrap());
1348 assert!(parse_boolish("maybe").is_err());
1349 }
1350
1351 #[test]
1354 fn test_effective_model_returns_last_used() {
1355 let mut settings = Settings::default();
1356 settings.last_used_model = Some("openai/gpt-4o".to_string());
1357 assert_eq!(
1358 settings.effective_model(None),
1359 Some("openai/gpt-4o".to_string())
1360 );
1361 }
1362
1363 #[test]
1364 fn test_effective_model_cli_overrides() {
1365 let mut settings = Settings::default();
1366 settings.last_used_model = Some("openai/gpt-4o".to_string());
1367 assert_eq!(
1368 settings.effective_model(Some("anthropic/claude-3")),
1369 Some("anthropic/claude-3".to_string())
1370 );
1371 }
1372
1373 #[test]
1374 fn test_effective_model_none_when_unset() {
1375 let settings = Settings::default();
1376 assert_eq!(settings.effective_model(None), None);
1377 }
1378
1379 #[test]
1380 fn test_effective_model_falls_back_to_last_used() {
1381 let mut settings = Settings::default();
1382 settings.last_used_model = Some("anthropic/claude-3".to_string());
1383 assert_eq!(
1384 settings.effective_model(None),
1385 Some("anthropic/claude-3".to_string())
1386 );
1387 }
1388
1389 #[test]
1390 fn test_effective_model_returns_none_when_nothing_set() {
1391 let settings = Settings::default();
1392 assert_eq!(settings.effective_model(None), None);
1393 }
1394
1395 #[test]
1396 fn test_effective_temperature_prefers_f64() {
1397 let mut settings = Settings::default();
1398 settings.temperature = Some(0.5);
1399 settings.default_temperature = Some(0.7);
1400 assert_eq!(settings.effective_temperature(), Some(0.7));
1401 }
1402
1403 #[test]
1404 fn test_effective_temperature_falls_back_to_f32() {
1405 let mut settings = Settings::default();
1406 settings.temperature = Some(0.5);
1407 assert_eq!(settings.effective_temperature(), Some(0.5));
1408 }
1409
1410 #[test]
1411 fn test_effective_max_tokens_prefers_usize() {
1412 let mut settings = Settings::default();
1413 settings.max_tokens = Some(1024);
1414 settings.max_response_tokens = Some(4096);
1415 assert_eq!(settings.effective_max_tokens(), Some(4096));
1416 }
1417
1418 #[test]
1419 fn test_effective_max_tokens_falls_back_to_u32() {
1420 let mut settings = Settings::default();
1421 settings.max_tokens = Some(1024);
1422 assert_eq!(settings.effective_max_tokens(), Some(1024));
1423 }
1424
1425 #[test]
1428 fn test_effective_session_dir_default() {
1429 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1430 let settings = Settings::default();
1431 let dir = settings.effective_session_dir().unwrap();
1432 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1433 }
1434
1435 #[test]
1436 fn test_effective_session_dir_from_field() {
1437 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1438 let mut settings = Settings::default();
1439 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1440 assert_eq!(
1441 settings.effective_session_dir().unwrap(),
1442 PathBuf::from("/tmp/oxi-sessions")
1443 );
1444 }
1445
1446 #[test]
1447 fn test_effective_session_dir_env_disabled() {
1448 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1451 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1452 let settings = Settings::default();
1453 let dir = settings.effective_session_dir().unwrap();
1455 assert!(
1456 dir.ends_with("sessions"),
1457 "expected default sessions dir, got: {:?}",
1458 dir
1459 );
1460 }
1461
1462 #[test]
1465 fn test_migration_v0_to_v1() {
1466 let mut settings = Settings::default();
1467 settings.version = 0;
1468 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1471 assert_eq!(migrated.version, SETTINGS_VERSION);
1472 assert_eq!(migrated.tool_timeout_seconds, 120);
1473 }
1474
1475 #[test]
1476 fn test_migration_already_current() {
1477 let settings = Settings::default();
1478 let migrated = Settings::migrate(settings).unwrap();
1479 assert_eq!(migrated.version, SETTINGS_VERSION);
1480 }
1481
1482 #[test]
1483 fn test_migration_v3_to_v4_splits_model() {
1484 let mut settings = Settings::default();
1485 settings.version = 3;
1486 settings.default_model = Some("openai/gpt-4o".to_string());
1487 settings.default_provider = None;
1488
1489 let migrated = Settings::migrate(settings).unwrap();
1490 assert_eq!(migrated.version, SETTINGS_VERSION);
1491 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1492 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1493 }
1494
1495 #[test]
1496 fn test_migration_v3_no_slash_keeps_model() {
1497 let mut settings = Settings::default();
1498 settings.version = 3;
1499 settings.default_model = Some("bare-model-name".to_string());
1500
1501 let migrated = Settings::migrate(settings).unwrap();
1502 assert_eq!(migrated.version, SETTINGS_VERSION);
1503 assert_eq!(
1504 migrated.last_used_model,
1505 Some("bare-model-name".to_string())
1506 );
1507 }
1508
1509 #[test]
1510 fn test_migration_future_version_fails() {
1511 let mut settings = Settings::default();
1512 settings.version = 9999;
1513 assert!(Settings::migrate(settings).is_err());
1514 }
1515
1516 #[test]
1519 fn test_default_output_languages_is_empty() {
1520 let settings = Settings::default();
1521 assert!(
1522 settings.output_languages.is_empty(),
1523 "all channels should default to auto (empty map)"
1524 );
1525 }
1526
1527 #[test]
1528 fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1529 let mut settings = Settings::default();
1530 settings.version = 4;
1531 settings
1532 .output_languages
1533 .insert("response".to_string(), "ko".to_string());
1534 settings
1535 .output_languages
1536 .insert("commit_message".to_string(), "en".to_string());
1537
1538 let migrated = Settings::migrate(settings).unwrap();
1539 assert_eq!(migrated.version, SETTINGS_VERSION);
1540 assert_eq!(
1541 migrated.output_languages.get("response"),
1542 Some(&"ko".to_string())
1543 );
1544 assert_eq!(
1545 migrated.output_languages.get("commit_message"),
1546 Some(&"en".to_string())
1547 );
1548 }
1549
1550 #[test]
1551 fn test_migration_v4_to_v5_creates_empty_if_missing() {
1552 let mut settings = Settings::default();
1556 settings.version = 4;
1557 assert!(settings.output_languages.is_empty());
1558
1559 let migrated = Settings::migrate(settings).unwrap();
1560 assert_eq!(migrated.version, SETTINGS_VERSION);
1561 assert!(migrated.output_languages.is_empty());
1562 }
1563
1564 #[test]
1565 fn test_validate_keeps_user_defined_channel() {
1566 let mut settings = Settings::default();
1571 settings
1572 .output_languages
1573 .insert("pr_description".to_string(), "en".to_string()); settings
1575 .output_languages
1576 .insert("response".to_string(), "ko".to_string()); settings.validate_output_languages();
1579
1580 assert!(settings.output_languages.contains_key("pr_description"));
1581 assert!(settings.output_languages.contains_key("response"));
1582 assert_eq!(
1583 settings.output_languages.get("pr_description"),
1584 Some(&"en".to_string())
1585 );
1586 assert_eq!(
1587 settings.output_languages.get("response"),
1588 Some(&"ko".to_string())
1589 );
1590 }
1591
1592 #[test]
1593 fn test_validate_keeps_unknown_lang_with_warning() {
1594 let mut settings = Settings::default();
1595 settings
1596 .output_languages
1597 .insert("response".to_string(), "klingon".to_string()); settings
1599 .output_languages
1600 .insert("commit_message".to_string(), "en".to_string()); settings.validate_output_languages();
1603
1604 assert_eq!(
1607 settings.output_languages.get("response"),
1608 Some(&"klingon".to_string())
1609 );
1610 assert_eq!(
1611 settings.output_languages.get("commit_message"),
1612 Some(&"en".to_string())
1613 );
1614 }
1615
1616 #[test]
1617 fn test_known_channels_table_includes_core_four() {
1618 let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1619 assert!(keys.contains(&"response"));
1620 assert!(keys.contains(&"code_comment"));
1621 assert!(keys.contains(&"documentation"));
1622 assert!(keys.contains(&"commit_message"));
1623 }
1624
1625 #[test]
1626 fn test_known_langs_table_includes_auto_and_english() {
1627 let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1628 assert!(codes.contains(&"auto"));
1629 assert!(codes.contains(&"en"));
1630 }
1631
1632 #[test]
1633 fn test_save_and_load_roundtrip_preserves_output_languages() {
1634 let tmp = tempfile::tempdir().unwrap();
1635 let settings_path = tmp.path().join("settings.toml");
1636
1637 let mut original = Settings::default();
1638 original
1639 .output_languages
1640 .insert("response".to_string(), "ko".to_string());
1641 original
1642 .output_languages
1643 .insert("commit_message".to_string(), "en".to_string());
1644
1645 let content = toml::to_string_pretty(&original).unwrap();
1646 fs::write(&settings_path, &content).unwrap();
1647
1648 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1649 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1650
1651 assert_eq!(
1652 loaded.output_languages.get("response"),
1653 Some(&"ko".to_string())
1654 );
1655 assert_eq!(
1656 loaded.output_languages.get("commit_message"),
1657 Some(&"en".to_string())
1658 );
1659 }
1660
1661 #[test]
1664 fn test_save_and_load_roundtrip() {
1665 let tmp = tempfile::tempdir().unwrap();
1666 let settings_path = tmp.path().join("settings.toml");
1667
1668 let mut original = Settings::default();
1669 original.last_used_model = Some("gpt-4o".to_string());
1670 original.last_used_provider = Some("openai".to_string());
1671 original.theme = "dracula".to_string();
1672 original.tool_timeout_seconds = 60;
1673
1674 let content = toml::to_string_pretty(&original).unwrap();
1676 fs::write(&settings_path, &content).unwrap();
1677
1678 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1680 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1681
1682 assert_eq!(loaded.last_used_model, original.last_used_model);
1683 assert_eq!(loaded.theme, original.theme);
1684 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1685 }
1686
1687 #[test]
1688 fn test_toml_roundtrip_preserves_new_fields() {
1689 let mut settings = Settings::default();
1690 settings.default_temperature = Some(0.8);
1691 settings.max_response_tokens = Some(8192);
1692 settings.auto_compaction = false;
1693 settings.extensions_enabled = false;
1694 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1695
1696 let toml_str = toml::to_string_pretty(&settings).unwrap();
1697 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1698
1699 assert_eq!(parsed.default_temperature, Some(0.8));
1700 assert_eq!(parsed.max_response_tokens, Some(8192));
1701 assert!(!parsed.auto_compaction);
1702 assert!(!parsed.extensions_enabled);
1703 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1704 }
1705
1706 #[test]
1709 fn test_json_roundtrip() {
1710 let mut settings = Settings::default();
1711 settings.last_used_model = Some("gpt-4o".to_string());
1712 settings.last_used_provider = Some("openai".to_string());
1713 settings.theme = "dracula".to_string();
1714 settings.tool_timeout_seconds = 60;
1715 settings.default_temperature = Some(0.8);
1716 settings.max_response_tokens = Some(8192);
1717
1718 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1719 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1720
1721 assert_eq!(parsed.last_used_model, settings.last_used_model);
1722 assert_eq!(parsed.theme, settings.theme);
1723 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1724 assert_eq!(parsed.default_temperature, settings.default_temperature);
1725 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1726 }
1727
1728 #[test]
1729 fn test_json_serialize_for_format() {
1730 let mut settings = Settings::default();
1731 settings.last_used_model = Some("claude-3".to_string());
1732 settings.last_used_provider = Some("anthropic".to_string());
1733 settings.thinking_level = ThinkingLevel::Minimal;
1734
1735 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1736 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1737
1738 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1739 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1740 }
1741
1742 #[test]
1743 fn test_toml_serialize_for_format() {
1744 let mut settings = Settings::default();
1745 settings.last_used_model = Some("gemini-pro".to_string());
1746 settings.last_used_provider = Some("google".to_string());
1747 settings.thinking_level = ThinkingLevel::High;
1748
1749 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1750 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1751
1752 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1753 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1754 }
1755
1756 #[test]
1757 fn test_parse_from_str_json() {
1758 let json_content = r#"{
1759 "last_used_model": "gpt-4",
1760 "last_used_provider": "openai",
1761 "theme": "nord",
1762 "tool_timeout_seconds": 90
1763 }"#;
1764
1765 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1766 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1767 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1768 assert_eq!(settings.theme, "nord");
1769 assert_eq!(settings.tool_timeout_seconds, 90);
1770 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1772 assert!(settings.extensions_enabled);
1773 }
1774
1775 #[test]
1776 fn test_parse_from_str_toml() {
1777 let toml_content = r#"
1778last_used_model = "claude-opus"
1779last_used_provider = "anthropic"
1780theme = "monokai"
1781tool_timeout_seconds = 45
1782"#;
1783
1784 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1785 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1786 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1787 assert_eq!(settings.theme, "monokai");
1788 assert_eq!(settings.tool_timeout_seconds, 45);
1789 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1790 }
1791
1792 #[test]
1793 fn test_layer_file_json() {
1794 let base = Settings::default();
1795
1796 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1797 let json_content = r#"{
1798 "last_used_model": "gpt-4o",
1799 "last_used_provider": "openai",
1800 "theme": "dracula",
1801 "auto_compaction": false
1802 }"#;
1803 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1804
1805 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1806 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1807 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1808 assert_eq!(merged.theme, "dracula");
1809 assert!(!merged.auto_compaction);
1810 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1812 assert!(merged.extensions_enabled);
1813 assert_eq!(merged.tool_timeout_seconds, 120);
1814 }
1815
1816 #[test]
1817 fn test_layer_file_json_preserves_unset() {
1818 let mut base = Settings::default();
1819 base.last_used_provider = Some("deepseek".to_string());
1820
1821 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1822 let json_content = r#"{ "theme": "nord" }"#;
1823 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1824
1825 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1826 assert_eq!(merged.theme, "nord");
1827 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1828 }
1829
1830 #[test]
1831 fn test_save_to_json() {
1832 let tmp = tempfile::tempdir().unwrap();
1833 let settings_path = tmp.path().join("settings.json");
1834
1835 let mut settings = Settings::default();
1836 settings.last_used_model = Some("gpt-4o".to_string());
1837 settings.last_used_provider = Some("openai".to_string());
1838 settings.theme = "dracula".to_string();
1839 settings.tool_timeout_seconds = 60;
1840
1841 settings.save_to(&settings_path).unwrap();
1842
1843 let content = fs::read_to_string(&settings_path).unwrap();
1845 let parsed: Settings = serde_json::from_str(&content).unwrap();
1846 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1847 assert_eq!(parsed.theme, "dracula");
1848 assert_eq!(parsed.tool_timeout_seconds, 60);
1849 }
1850
1851 #[test]
1852 fn test_save_to_toml() {
1853 let tmp = tempfile::tempdir().unwrap();
1854 let settings_path = tmp.path().join("settings.toml");
1855
1856 let mut settings = Settings::default();
1857 settings.last_used_model = Some("gemini-pro".to_string());
1858 settings.last_used_provider = Some("google".to_string());
1859 settings.theme = "monokai".to_string();
1860 settings.tool_timeout_seconds = 90;
1861
1862 settings.save_to(&settings_path).unwrap();
1863
1864 let content = fs::read_to_string(&settings_path).unwrap();
1866 let parsed: Settings = toml::from_str(&content).unwrap();
1867 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1868 assert_eq!(parsed.theme, "monokai");
1869 assert_eq!(parsed.tool_timeout_seconds, 90);
1870 }
1871
1872 #[test]
1873 fn test_load_from_dir_with_json_project_config() {
1874 let _guard = EnvGuard::new(&[
1875 "OXI_MODEL",
1876 "OXI_PROVIDER",
1877 "OXI_THEME",
1878 "OXI_TOOL_TIMEOUT",
1879 "OXI_TEMPERATURE",
1880 "OXI_MAX_TOKENS",
1881 "OXI_SESSION_DIR",
1882 "OXI_STREAM",
1883 "OXI_EXTENSIONS_ENABLED",
1884 ]);
1885 let tmp = tempfile::tempdir().unwrap();
1886 let oxi_dir = tmp.path().join(".oxi");
1887 fs::create_dir_all(&oxi_dir).unwrap();
1888 let settings_path = oxi_dir.join("settings.json");
1889 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1891 fs::write(&settings_path, json_content).unwrap();
1892
1893 let settings = Settings::load_from(tmp.path()).unwrap();
1894 assert_eq!(
1896 settings.last_used_model,
1897 Some("gemini-2.0-flash".to_string())
1898 );
1899 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1900 }
1901
1902 #[test]
1903 fn test_find_project_settings_json_priority() {
1904 let tmp = tempfile::tempdir().unwrap();
1905 let oxi_dir = tmp.path().join(".oxi");
1906 fs::create_dir_all(&oxi_dir).unwrap();
1907
1908 let json_path = oxi_dir.join("settings.json");
1910 let toml_path = oxi_dir.join("settings.toml");
1911 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1912 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1913
1914 let found = Settings::find_project_settings(tmp.path());
1916 assert!(found.is_some());
1917 assert_eq!(
1918 found.unwrap().file_name().unwrap().to_str().unwrap(),
1919 "settings.json"
1920 );
1921 }
1922
1923 #[test]
1924 fn test_find_project_settings_json_only() {
1925 let tmp = tempfile::tempdir().unwrap();
1926 let oxi_dir = tmp.path().join(".oxi");
1927 fs::create_dir_all(&oxi_dir).unwrap();
1928
1929 let json_path = oxi_dir.join("settings.json");
1930 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1931
1932 let found = Settings::find_project_settings(tmp.path());
1933 assert!(found.is_some());
1934 assert_eq!(
1935 found.unwrap().file_name().unwrap().to_str().unwrap(),
1936 "settings.json"
1937 );
1938 }
1939
1940 #[test]
1941 fn test_find_project_settings_toml_fallback() {
1942 let tmp = tempfile::tempdir().unwrap();
1943 let oxi_dir = tmp.path().join(".oxi");
1944 fs::create_dir_all(&oxi_dir).unwrap();
1945
1946 let toml_path = oxi_dir.join("settings.toml");
1947 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1948
1949 let found = Settings::find_project_settings(tmp.path());
1950 assert!(found.is_some());
1951 assert_eq!(
1952 found.unwrap().file_name().unwrap().to_str().unwrap(),
1953 "settings.toml"
1954 );
1955 }
1956
1957 #[test]
1958 fn test_detect_format() {
1959 let json_path = PathBuf::from("/test/settings.json");
1960 let toml_path = PathBuf::from("/test/settings.toml");
1961 let unknown_path = PathBuf::from("/test/settings");
1962
1963 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1964 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1965 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1966 }
1968
1969 #[test]
1970 fn test_settings_format_extension() {
1971 assert_eq!(SettingsFormat::Json.extension(), "json");
1972 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1973 }
1974
1975 #[test]
1976 fn test_layer_json_over_toml() {
1977 let tmp = tempfile::tempdir().unwrap();
1979 let oxi_dir = tmp.path().join(".oxi");
1980 fs::create_dir_all(&oxi_dir).unwrap();
1981
1982 let json_path = oxi_dir.join("settings.json");
1983 let toml_path = oxi_dir.join("settings.toml");
1984
1985 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1987 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1989
1990 let settings = Settings::load_from(tmp.path()).unwrap();
1992 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1993 }
1994
1995 #[test]
1996 fn test_mixed_format_loading() {
1997 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1999 let toml_content = r#"
2000last_used_model = "loaded-via-toml"
2001theme = "loaded-theme"
2002stream_responses = false
2003"#;
2004 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2005
2006 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2007 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2008 assert_eq!(merged.theme, "loaded-theme");
2009 assert!(!merged.stream_responses);
2010 }
2011
2012 #[test]
2013 fn test_merge_json_values() {
2014 let base = serde_json::json!({
2015 "version": 1,
2016 "theme": "default",
2017 "extensions": ["ext1"],
2018 "nested": {
2019 "a": 1,
2020 "b": 2
2021 }
2022 });
2023
2024 let override_ = serde_json::json!({
2025 "version": 2,
2026 "theme": "dark",
2027 "extensions": ["ext2"],
2028 "nested": {
2029 "b": 20,
2030 "c": 30
2031 }
2032 });
2033
2034 let merged = merge_json_values(base, override_);
2035
2036 assert_eq!(merged["version"], 2);
2037 assert_eq!(merged["theme"], "dark");
2038 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2040 assert_eq!(merged["nested"]["a"], 1);
2042 assert_eq!(merged["nested"]["b"], 20);
2043 assert_eq!(merged["nested"]["c"], 30);
2044 }
2045
2046 #[test]
2047 fn test_save_project_preserves_existing_format() {
2048 let tmp = tempfile::tempdir().unwrap();
2049 let oxi_dir = tmp.path().join(".oxi");
2050 fs::create_dir_all(&oxi_dir).unwrap();
2051
2052 let toml_path = oxi_dir.join("settings.toml");
2054 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2055
2056 let mut settings = Settings::default();
2057 settings.theme = "new-theme".to_string();
2058 settings.save_project(tmp.path()).unwrap();
2059
2060 let content = fs::read_to_string(&toml_path).unwrap();
2062 assert!(content.contains("new-theme"));
2063 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2064 }
2065
2066 #[test]
2067 fn test_save_project_creates_json_by_default() {
2068 let tmp = tempfile::tempdir().unwrap();
2069 let oxi_dir = tmp.path().join(".oxi");
2070 fs::create_dir_all(&oxi_dir).unwrap();
2071 let mut settings = Settings::default();
2074 settings.theme = "json-theme".to_string();
2075 settings.save_project(tmp.path()).unwrap();
2076
2077 let json_path = oxi_dir.join("settings.json");
2079 assert!(json_path.exists());
2080 let content = fs::read_to_string(&json_path).unwrap();
2081 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2082 assert!(content.contains("json-theme"));
2083 }
2084
2085 #[test]
2088 fn test_custom_provider_default_api() {
2089 use super::CustomProvider;
2090 let cp = CustomProvider {
2091 name: "test".to_string(),
2092 base_url: "https://api.test.com/v1".to_string(),
2093 api_key_env: "TEST_API_KEY".to_string(),
2094 api: super::default_custom_provider_api(),
2095 };
2096 assert_eq!(cp.api, "openai-completions");
2097 }
2098
2099 #[test]
2100 fn test_custom_provider_toml_deserialize() {
2101 let toml_content = r#"
2102[[custom_providers]]
2103name = "minimax"
2104base_url = "https://api.minimax.chat/v1"
2105api_key_env = "MINIMAX_API_KEY"
2106api = "openai-completions"
2107
2108[[custom_providers]]
2109name = "zai"
2110base_url = "https://api.z.ai/v1"
2111api_key_env = "ZAI_API_KEY"
2112api = "openai-responses"
2113"#;
2114 let settings: Settings = toml::from_str(toml_content).unwrap();
2115 assert_eq!(settings.custom_providers.len(), 2);
2116 assert_eq!(settings.custom_providers[0].name, "minimax");
2117 assert_eq!(
2118 settings.custom_providers[0].base_url,
2119 "https://api.minimax.chat/v1"
2120 );
2121 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2122 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2123 assert_eq!(settings.custom_providers[1].name, "zai");
2124 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2125 }
2126
2127 #[test]
2128 fn test_custom_provider_json_deserialize() {
2129 let json_content = r#"{
2130 "custom_providers": [
2131 {
2132 "name": "minimax",
2133 "base_url": "https://api.minimax.chat/v1",
2134 "api_key_env": "MINIMAX_API_KEY",
2135 "api": "openai-completions"
2136 }
2137 ]
2138 }"#;
2139 let settings: Settings = serde_json::from_str(json_content).unwrap();
2140 assert_eq!(settings.custom_providers.len(), 1);
2141 assert_eq!(settings.custom_providers[0].name, "minimax");
2142 }
2143
2144 #[test]
2145 fn test_custom_provider_toml_roundtrip() {
2146 let mut settings = Settings::default();
2147 settings.custom_providers.push(super::CustomProvider {
2148 name: "test".to_string(),
2149 base_url: "https://api.test.com/v1".to_string(),
2150 api_key_env: "TEST_API_KEY".to_string(),
2151 api: "openai-completions".to_string(),
2152 });
2153
2154 let toml_str = toml::to_string_pretty(&settings).unwrap();
2155 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2156 assert_eq!(parsed.custom_providers.len(), 1);
2157 assert_eq!(parsed.custom_providers[0].name, "test");
2158 assert_eq!(
2159 parsed.custom_providers[0].base_url,
2160 "https://api.test.com/v1"
2161 );
2162 }
2163
2164 #[test]
2165 fn test_custom_provider_defaults_empty() {
2166 let settings = Settings::default();
2167 assert!(settings.custom_providers.is_empty());
2168 }
2169
2170 #[test]
2171 fn test_custom_provider_layer_file() {
2172 let base = Settings::default();
2173
2174 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2175 let toml_content = r#"
2176[[custom_providers]]
2177name = "my-provider"
2178base_url = "https://api.my-provider.com/v1"
2179api_key_env = "MY_PROVIDER_API_KEY"
2180"#;
2181 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2182
2183 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2184 assert_eq!(merged.custom_providers.len(), 1);
2185 assert_eq!(merged.custom_providers[0].name, "my-provider");
2186 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2188 }
2189}