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 = 6;
26
27pub const KNOWN_CHANNELS: &[(&str, &str)] = &[
38 ("response", "Your conversational responses to the user"),
39 (
40 "code_comment",
41 "Code comments you write (//, /* */, #, etc.)",
42 ),
43 (
44 "documentation",
45 "Documentation (markdown files, README, AGENTS.md, doc comments)",
46 ),
47 ("commit_message", "Git commit messages (subject + body)"),
48];
49
50pub const KNOWN_LANGS: &[(&str, &str)] = &[
61 ("auto", "Auto (match user)"),
62 ("en", "English"),
63 ("ko", "Korean (한국어)"),
64 ("ja", "Japanese (日本語)"),
65 ("zh", "Chinese (中文)"),
66 ("es", "Spanish"),
67 ("fr", "French"),
68 ("de", "German"),
69];
70
71#[allow(dead_code)]
74const ENV_PREFIX: &str = "OXI_";
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
78#[serde(rename_all = "snake_case")]
79pub enum ThinkingLevel {
80 #[default]
82 Off,
83 Minimal,
85 Low,
87 Medium,
89 High,
91 XHigh,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct CustomProvider {
101 pub name: String,
103 pub base_url: String,
105 pub api_key_env: String,
107 #[serde(default = "default_custom_provider_api")]
109 pub api: String,
110}
111
112fn default_custom_provider_api() -> String {
113 "openai-completions".to_string()
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Settings {
119 #[serde(default)]
122 pub version: u32,
123
124 #[serde(default = "default_thinking_level")]
127 pub thinking_level: ThinkingLevel,
128
129 #[serde(default = "default_theme")]
131 pub theme: String,
132
133 #[serde(default, skip_serializing)]
135 pub default_model: Option<String>,
136
137 #[serde(default, skip_serializing)]
139 pub default_provider: Option<String>,
140
141 #[serde(default)]
144 pub last_used_model: Option<String>,
145
146 #[serde(default)]
148 pub last_used_provider: Option<String>,
149
150 pub max_tokens: Option<u32>,
152
153 pub temperature: Option<f32>,
155
156 pub default_temperature: Option<f64>,
158
159 pub max_response_tokens: Option<usize>,
161
162 #[serde(default = "default_session_history_size")]
165 pub session_history_size: usize,
166
167 pub session_dir: Option<PathBuf>,
169
170 #[serde(default = "default_true")]
173 pub stream_responses: bool,
174
175 #[serde(default = "default_true")]
177 pub extensions_enabled: bool,
178
179 #[serde(default = "default_true")]
181 pub auto_compaction: bool,
182
183 #[serde(default)]
186 pub disabled_tools: Vec<String>,
187
188 #[serde(default = "default_tool_timeout")]
191 pub tool_timeout_seconds: u64,
192
193 #[serde(default)]
196 pub extensions: Vec<String>,
197
198 #[serde(default)]
200 pub skills: Vec<String>,
201
202 #[serde(default)]
204 pub prompts: Vec<String>,
205
206 #[serde(default)]
208 pub themes: Vec<String>,
209
210 #[serde(default)]
213 pub custom_providers: Vec<CustomProvider>,
214
215 #[serde(default)]
220 pub dynamic_models: HashMap<String, Vec<String>>,
221
222 #[serde(default = "default_false")]
225 pub enable_routing: bool,
226
227 #[serde(default)]
229 pub router_profile: Option<String>,
230
231 #[serde(default = "default_true")]
233 pub prefer_cost_efficient: bool,
234
235 #[serde(default)]
237 pub fallback_chain: Vec<String>,
238
239 #[serde(default = "default_true")]
241 pub enable_fallback: bool,
242
243 #[serde(default)]
245 pub disable_fallback: bool,
246
247 #[serde(default = "default_circuit_failure_threshold")]
249 pub circuit_breaker_failure_threshold: u32,
250
251 #[serde(default = "default_circuit_open_duration_secs")]
253 pub circuit_breaker_open_duration_secs: u64,
254
255 #[serde(default)]
260 pub keybindings: HashMap<String, Vec<String>>,
261
262 #[serde(default)]
308 pub output_languages: HashMap<String, String>,
309
310 #[serde(default = "default_false")]
329 pub language_policy_enabled: bool,
330}
331
332fn default_theme() -> String {
333 "default".to_string()
334}
335
336fn default_thinking_level() -> ThinkingLevel {
337 ThinkingLevel::Medium
338}
339
340fn default_session_history_size() -> usize {
341 100
342}
343
344fn default_true() -> bool {
345 true
346}
347
348fn default_false() -> bool {
349 false
350}
351
352fn default_circuit_failure_threshold() -> u32 {
353 5
354}
355
356fn default_circuit_open_duration_secs() -> u64 {
357 30
358}
359
360fn default_tool_timeout() -> u64 {
361 120
362}
363
364impl Default for Settings {
365 fn default() -> Self {
366 Self {
367 version: SETTINGS_VERSION,
368 thinking_level: ThinkingLevel::Medium,
369 theme: default_theme(),
370 last_used_model: None,
371 last_used_provider: None,
372 default_model: None,
373 default_provider: None,
374 max_tokens: None,
375 temperature: None,
376 default_temperature: None,
377 max_response_tokens: None,
378 session_history_size: default_session_history_size(),
379 session_dir: None,
380 stream_responses: true,
381 extensions_enabled: true,
382 auto_compaction: true,
383 disabled_tools: Vec::new(),
384 tool_timeout_seconds: default_tool_timeout(),
385 extensions: Vec::new(),
386 skills: Vec::new(),
387 prompts: Vec::new(),
388 themes: Vec::new(),
389 custom_providers: Vec::new(),
390 dynamic_models: HashMap::new(),
391 enable_routing: false,
393 router_profile: None,
394 prefer_cost_efficient: true,
395 fallback_chain: Vec::new(),
396 enable_fallback: true,
397 disable_fallback: false,
398 circuit_breaker_failure_threshold: 5,
399 circuit_breaker_open_duration_secs: 30,
400 keybindings: HashMap::new(),
401 output_languages: HashMap::new(),
402 language_policy_enabled: false,
403 }
404 }
405}
406
407impl Settings {
408 pub fn settings_dir() -> Result<PathBuf> {
412 let base = dirs::home_dir().context("Cannot determine home directory")?;
413 Ok(base.join(".oxi"))
414 }
415
416 pub fn settings_toml_path() -> Result<PathBuf> {
418 Ok(Self::settings_dir()?.join("settings.toml"))
419 }
420
421 pub fn settings_json_path() -> Result<PathBuf> {
423 Ok(Self::settings_dir()?.join("settings.json"))
424 }
425
426 pub fn settings_path() -> Result<PathBuf> {
433 let json_path = Self::settings_json_path()?;
434 let toml_path = Self::settings_toml_path()?;
435
436 if json_path.exists() && toml_path.exists() {
437 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
439 return Ok(json_path);
440 }
441
442 if json_path.exists() {
443 return Ok(json_path);
444 }
445
446 if toml_path.exists() {
447 return Ok(toml_path);
448 }
449
450 Ok(json_path)
452 }
453
454 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
459 let json_path = Self::settings_json_path()?;
460 let toml_path = Self::settings_toml_path()?;
461
462 let (primary, secondary) = if prefer_json {
463 (&json_path, &toml_path)
464 } else {
465 (&toml_path, &json_path)
466 };
467
468 if primary.exists() {
469 return Ok(primary.clone());
470 }
471
472 if secondary.exists() {
473 return Ok(secondary.clone());
474 }
475
476 Ok(primary.clone())
478 }
479
480 pub fn detect_format(path: &Path) -> SettingsFormat {
482 match path.extension().and_then(|e| e.to_str()) {
483 Some("json") => SettingsFormat::Json,
484 Some("toml") => SettingsFormat::Toml,
485 _ => SettingsFormat::Json, }
487 }
488
489 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
494 let mut dir = start_dir.to_path_buf();
495 loop {
496 let json_candidate = dir.join(".oxi").join("settings.json");
498 if json_candidate.exists() {
499 return Some(json_candidate);
500 }
501
502 let toml_candidate = dir.join(".oxi").join("settings.toml");
503 if toml_candidate.exists() {
504 return Some(toml_candidate);
505 }
506
507 if !dir.pop() {
508 return None;
509 }
510 }
511 }
512
513 pub fn effective_session_dir(&self) -> Result<PathBuf> {
517 if let Some(ref dir) = self.session_dir {
518 return Ok(dir.clone());
519 }
520 Ok(Self::settings_dir()?.join("sessions"))
521 }
522
523 pub fn load() -> Result<Self> {
541 Self::load_from_cwd()
542 }
543
544 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
546 let mut settings = Settings::default();
548
549 if let Ok(global_path) = Self::settings_path()
551 && global_path.exists()
552 {
553 settings = Self::layer_file(&settings, &global_path)?;
554 }
555
556 if let Some(project_path) = Self::find_project_settings(dir) {
558 settings = Self::layer_file(&settings, &project_path)?;
559 }
560
561 settings.apply_env();
563
564 settings = Self::migrate(settings)?;
566
567 settings.validate_output_languages();
569
570 Ok(settings)
571 }
572
573 fn validate_output_languages(&mut self) {
580 if self.output_languages.is_empty() {
581 return;
582 }
583 let known_langs: std::collections::HashSet<&str> =
584 KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
585
586 for (channel, lang) in &self.output_languages {
587 if !known_langs.contains(lang.as_str()) {
588 tracing::warn!(
589 "Unknown output_languages language code '{}' for channel '{}'. \
590 Keeping as-is (the model will likely understand).",
591 lang,
592 channel
593 );
594 }
595 }
596 }
597
598 pub fn load_from_cwd() -> Result<Self> {
600 let cwd = env::current_dir().context("Cannot determine current directory")?;
601 Self::load_from(&cwd)
602 }
603
604 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
610 let content = fs::read_to_string(path)
611 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
612
613 let format = Self::detect_format(path);
614 let overlay: serde_json::Value = match format {
615 SettingsFormat::Toml => {
616 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
617 format!("Failed to parse TOML settings from {}", path.display())
618 })?;
619 toml_value_to_json(toml_value)
621 }
622 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
623 format!("Failed to parse JSON settings from {}", path.display())
624 })?,
625 };
626
627 let base_json =
631 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
632
633 let merged = merge_json_values(base_json, overlay);
634 let result: Settings =
635 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
636
637 Ok(result)
638 }
639
640 #[allow(dead_code)]
666 pub fn apply_env(&mut self) {
667 }
671
672 #[allow(dead_code)]
678 pub fn from_env() -> Self {
679 Self::default()
680 }
681
682 pub fn save(&self) -> Result<()> {
689 let dir = Self::settings_dir()?;
690 let path = Self::settings_path()?;
691
692 if !dir.exists() {
693 fs::create_dir_all(&dir).with_context(|| {
694 format!("Failed to create settings directory {}", dir.display())
695 })?;
696 }
697
698 let format = Self::detect_format(&path);
699 let content = Self::serialize_for_format(self, format)?;
700
701 let tmp_path = path.with_extension("tmp");
703 fs::write(&tmp_path, &content)
704 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
705 fs::rename(&tmp_path, &path)
706 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
707
708 Ok(())
709 }
710
711 pub fn save_to(&self, path: &Path) -> Result<()> {
713 if let Some(parent) = path.parent()
714 && !parent.exists()
715 {
716 fs::create_dir_all(parent)
717 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
718 }
719
720 let format = Self::detect_format(path);
721 let content = Self::serialize_for_format(self, format)?;
722
723 let tmp_path = path.with_extension("tmp");
725 fs::write(&tmp_path, &content)
726 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
727 fs::rename(&tmp_path, path)
728 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
729
730 Ok(())
731 }
732
733 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
737 let dir = project_dir.join(".oxi");
738
739 if !dir.exists() {
740 fs::create_dir_all(&dir).with_context(|| {
741 format!(
742 "Failed to create project settings directory {}",
743 dir.display()
744 )
745 })?;
746 }
747
748 let json_path = dir.join("settings.json");
750 let toml_path = dir.join("settings.toml");
751
752 let path = if json_path.exists() {
753 &json_path
754 } else if toml_path.exists() {
755 &toml_path
756 } else {
757 &json_path
759 };
760
761 let format = Self::detect_format(path);
762 let content = Self::serialize_for_format(self, format)?;
763
764 let tmp_path = path.with_extension("tmp");
766 fs::write(&tmp_path, &content)
767 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
768 fs::rename(&tmp_path, path)
769 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
770
771 Ok(())
772 }
773
774 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
776 match format {
777 SettingsFormat::Toml => {
778 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
779 }
780 SettingsFormat::Json => serde_json::to_string_pretty(settings)
781 .context("Failed to serialize settings to JSON"),
782 }
783 }
784
785 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
787 match format {
788 SettingsFormat::Toml => {
789 toml::from_str(content).context("Failed to parse TOML settings")
790 }
791 SettingsFormat::Json => {
792 serde_json::from_str(content).context("Failed to parse JSON settings")
793 }
794 }
795 }
796
797 pub fn merge_cli(
810 &mut self,
811 model: Option<String>,
812 provider: Option<String>,
813 enable_routing: Option<bool>,
814 prefer_cost_efficient: Option<bool>,
815 fallback_chain: Option<Vec<String>>,
816 disable_fallback: Option<bool>,
817 ) {
818 if let Some(m) = model {
819 self.last_used_model = Some(m);
820 }
821 if let Some(p) = provider {
822 self.last_used_provider = Some(p);
823 }
824 if let Some(r) = enable_routing {
825 self.enable_routing = r;
826 }
827 if let Some(p) = prefer_cost_efficient {
828 self.prefer_cost_efficient = p;
829 }
830 if let Some(fc) = fallback_chain
831 && !fc.is_empty()
832 {
833 self.fallback_chain = fc;
834 }
835 if let Some(df) = disable_fallback {
836 self.disable_fallback = df;
837 if df {
839 self.enable_fallback = false;
840 }
841 }
842 }
843
844 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
847 cli_model.map(String::from).or_else(|| {
848 let model = self.last_used_model.as_ref()?;
853 if model.contains('/') {
854 Some(model.clone())
856 } else if let Some(ref provider) = self.last_used_provider {
857 Some(format!("{}/{}", provider, model))
859 } else {
860 Some(model.clone())
861 }
862 })
863 }
864
865 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
868 cli_provider
869 .map(String::from)
870 .or_else(|| self.last_used_provider.clone())
871 }
872
873 pub fn effective_temperature(&self) -> Option<f64> {
876 self.default_temperature
877 .or(self.temperature.map(|t| t as f64))
878 }
879
880 pub fn effective_max_tokens(&self) -> Option<usize> {
883 self.max_response_tokens
884 .or(self.max_tokens.map(|t| t as usize))
885 }
886
887 pub fn router_profile(&self) -> Option<&str> {
889 self.router_profile.as_deref()
890 }
891
892 pub fn save_last_used(model_id: &str) {
898 if let Ok(mut settings) = Self::load() {
899 if let Some((provider, model)) = model_id.split_once('/') {
900 settings.last_used_provider = Some(provider.to_string());
901 settings.last_used_model = Some(model.to_string());
902 } else {
903 settings.last_used_model = Some(model_id.to_string());
904 }
905 let _ = settings.save();
906 }
907 }
908
909 pub fn save_theme(&mut self, name: &str) -> Result<()> {
911 self.theme = name.to_string();
912 self.save()
913 }
914
915 pub fn get_theme_name(&self) -> String {
917 if self.theme.is_empty() || self.theme == "default" {
918 "oxi_dark".to_string()
919 } else {
920 self.theme.clone()
921 }
922 }
923
924 fn migrate(settings: Settings) -> Result<Settings> {
938 let mut settings = settings;
939
940 match settings.version {
941 SETTINGS_VERSION => {
942 }
944 0 => {
945 if settings.tool_timeout_seconds == 0 {
948 settings.tool_timeout_seconds = default_tool_timeout();
949 }
950 settings.version = SETTINGS_VERSION;
951
952 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
953 }
954 1 | 2 => {
955 settings.version = SETTINGS_VERSION;
960 tracing::info!(
961 "Migrated settings from version {} to {} (dynamic_models + output_languages + language_policy_enabled defaults applied)",
962 settings.version,
963 SETTINGS_VERSION
964 );
965 }
966 3 => {
967 if let Some(model) = settings.default_model.take() {
969 if let Some((provider, model_name)) = model.split_once('/') {
970 settings.last_used_provider = Some(provider.to_string());
971 settings.last_used_model = Some(model_name.to_string());
972 } else {
973 settings.last_used_model = Some(model);
974 }
975 }
976 settings.version = SETTINGS_VERSION;
978 tracing::info!(
979 "Migrated settings from version 3 to {} (default_model → last_used_model; output_languages + language_policy_enabled defaults)",
980 SETTINGS_VERSION
981 );
982 }
983 4 => {
984 settings.version = SETTINGS_VERSION;
988 tracing::info!(
989 "Migrated settings from version 4 to {} (added output_languages + language_policy_enabled, both defaulted to off)",
990 SETTINGS_VERSION
991 );
992 }
993 5 => {
994 settings.version = SETTINGS_VERSION;
1000 tracing::info!(
1001 "Migrated settings from version 5 to {} (added language_policy_enabled, defaulting to OFF — toggle ON in /settings to activate existing channels)",
1002 SETTINGS_VERSION
1003 );
1004 }
1005 v if v > SETTINGS_VERSION => {
1006 anyhow::bail!(
1008 "Settings version {} is newer than supported version {}. \
1009 Please update oxi.",
1010 v,
1011 SETTINGS_VERSION
1012 );
1013 }
1014 v => {
1015 tracing::warn!(
1017 "Unknown settings version {}, attempting migration to {}",
1018 v,
1019 SETTINGS_VERSION
1020 );
1021 settings.version = SETTINGS_VERSION;
1022 }
1023 }
1024
1025 Ok(settings)
1026 }
1027}
1028
1029#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1033pub enum SettingsFormat {
1034 #[default]
1036 Json,
1037 Toml,
1039}
1040
1041impl SettingsFormat {
1042 pub fn extension(&self) -> &'static str {
1044 match self {
1045 SettingsFormat::Json => "json",
1046 SettingsFormat::Toml => "toml",
1047 }
1048 }
1049}
1050
1051fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1055 match toml {
1056 toml::Value::String(s) => serde_json::Value::String(s),
1057 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1058 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1059 .map(serde_json::Value::Number)
1060 .unwrap_or(serde_json::Value::Null),
1061 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1062 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1063 toml::Value::Array(arr) => {
1064 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1065 }
1066 toml::Value::Table(table) => {
1067 let obj = table
1068 .into_iter()
1069 .map(|(k, v)| (k, toml_value_to_json(v)))
1070 .collect();
1071 serde_json::Value::Object(obj)
1072 }
1073 }
1074}
1075
1076fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1078 match (base, override_) {
1079 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1081 let mut result = base_map;
1082 for (key, override_value) in override_map {
1083 let base_value = result.remove(&key);
1084 let merged = match base_value {
1085 Some(base_v) => merge_json_values(base_v, override_value),
1086 None => override_value,
1087 };
1088 result.insert(key, merged);
1089 }
1090 serde_json::Value::Object(result)
1091 }
1092 (_, override_) => override_,
1094 }
1095}
1096
1097pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1099 match s.to_lowercase().as_str() {
1100 "off" | "none" => Some(ThinkingLevel::Off),
1101 "minimal" => Some(ThinkingLevel::Minimal),
1102 "low" => Some(ThinkingLevel::Low),
1103 "medium" | "standard" => Some(ThinkingLevel::Medium),
1104 "high" | "thorough" => Some(ThinkingLevel::High),
1105 "xhigh" => Some(ThinkingLevel::XHigh),
1106 _ => None,
1107 }
1108}
1109
1110#[allow(dead_code)]
1112fn parse_boolish(s: &str) -> Result<bool> {
1113 match s.to_lowercase().as_str() {
1114 "true" | "1" | "yes" | "on" => Ok(true),
1115 "false" | "0" | "no" | "off" => Ok(false),
1116 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1117 }
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122 use super::*;
1123 use std::io::Write as IoWrite;
1124 use std::sync::Mutex;
1125
1126 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1129
1130 struct EnvGuard {
1133 saved: Vec<(String, Option<String>)>,
1134 }
1135
1136 impl EnvGuard {
1137 fn new(vars: &[&str]) -> Self {
1138 let saved = vars
1139 .iter()
1140 .map(|&name| {
1141 let old = env::var(name).ok();
1142 unsafe { env::remove_var(name) };
1144 (name.to_string(), old)
1145 })
1146 .collect();
1147 Self { saved }
1148 }
1149 }
1150
1151 impl Drop for EnvGuard {
1152 fn drop(&mut self) {
1153 for (name, old) in self.saved.drain(..) {
1154 match old {
1155 Some(val) => unsafe { env::set_var(&name, val) },
1157 None => unsafe { env::remove_var(&name) },
1158 }
1159 }
1160 }
1161 }
1162
1163 #[test]
1166 fn test_default_settings() {
1167 let settings = Settings::default();
1168 assert_eq!(settings.version, SETTINGS_VERSION);
1169 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1170 assert_eq!(settings.theme, "default");
1171 assert!(settings.last_used_model.is_none());
1172 assert!(settings.last_used_provider.is_none());
1173 assert!(settings.extensions_enabled);
1174 assert!(settings.auto_compaction);
1175 assert_eq!(settings.tool_timeout_seconds, 120);
1176 assert!(settings.stream_responses);
1177 }
1178
1179 #[test]
1180 fn test_merge_cli() {
1181 let mut settings = Settings::default();
1182 settings.last_used_model = Some("gpt-4o".to_string());
1183
1184 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1185 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1186
1187 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1188 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1189
1190 settings.merge_cli(
1192 None,
1193 None,
1194 Some(true),
1195 Some(false),
1196 Some(vec!["openai/gpt-4o".to_string()]),
1197 Some(false),
1198 );
1199 assert!(settings.enable_routing);
1200 assert!(!settings.prefer_cost_efficient);
1201 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1202 assert!(!settings.disable_fallback);
1203
1204 let mut settings2 = Settings::default();
1206 settings2.merge_cli(None, None, None, None, None, Some(true));
1207 assert!(settings2.disable_fallback);
1208 assert!(!settings2.enable_fallback);
1209 }
1210
1211 #[test]
1214 fn test_layer_file_overrides() {
1215 let base = Settings::default();
1216
1217 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1218 let toml_content = r#"
1219last_used_model = "openai/gpt-4o"
1220theme = "dracula"
1221"#;
1222 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1223
1224 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1225 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1226 assert_eq!(merged.theme, "dracula");
1227 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1229 assert!(merged.extensions_enabled);
1230 }
1231
1232 #[test]
1233 fn test_layer_file_preserves_unset() {
1234 let mut base = Settings::default();
1235 base.last_used_provider = Some("deepseek".to_string());
1236
1237 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1238 let toml_content = "theme = \"monokai\"\n";
1240 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1241
1242 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1243 assert_eq!(merged.theme, "monokai");
1244 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1245 }
1246
1247 #[test]
1248 fn test_load_from_dir_with_project_config() {
1249 let _guard = EnvGuard::new(&[
1250 "OXI_MODEL",
1251 "OXI_PROVIDER",
1252 "OXI_THEME",
1253 "OXI_TOOL_TIMEOUT",
1254 "OXI_TEMPERATURE",
1255 "OXI_MAX_TOKENS",
1256 "OXI_SESSION_DIR",
1257 "OXI_STREAM",
1258 "OXI_EXTENSIONS_ENABLED",
1259 ]);
1260 let tmp = tempfile::tempdir().unwrap();
1261 let oxi_dir = tmp.path().join(".oxi");
1262 fs::create_dir_all(&oxi_dir).unwrap();
1263 let settings_path = oxi_dir.join("settings.toml");
1264 fs::write(
1266 &settings_path,
1267 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1268 )
1269 .unwrap();
1270
1271 let settings = Settings::load_from(tmp.path()).unwrap();
1272 assert_eq!(
1274 settings.last_used_model,
1275 Some("gemini-2.0-flash".to_string())
1276 );
1277 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1278 }
1279
1280 #[test]
1281 fn test_load_from_dir_no_config() {
1282 let _guard = EnvGuard::new(&[
1284 "OXI_MODEL",
1285 "OXI_PROVIDER",
1286 "OXI_THEME",
1287 "OXI_TOOL_TIMEOUT",
1288 "OXI_TEMPERATURE",
1289 "OXI_MAX_TOKENS",
1290 "OXI_SESSION_DIR",
1291 "OXI_STREAM",
1292 "OXI_EXTENSIONS_ENABLED",
1293 ]);
1294 let tmp = tempfile::tempdir().unwrap();
1295 let settings = Settings::load_from(tmp.path()).unwrap();
1296 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1298 }
1299
1300 #[test]
1303 fn test_from_env() {
1304 let _guard = EnvGuard::new(&[
1307 "OXI_MODEL",
1309 "OXI_THEME",
1310 "OXI_TOOL_TIMEOUT",
1311 "OXI_PROVIDER",
1312 "OXI_DEFAULT_MODEL",
1313 ]);
1314
1315 let settings = Settings::from_env();
1316 assert_eq!(settings.last_used_model, None);
1318 assert_eq!(settings.theme, "default");
1319 assert_eq!(settings.tool_timeout_seconds, 120);
1320 }
1321
1322 #[test]
1323 fn test_apply_env_boolish() {
1324 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1327 unsafe { env::set_var("OXI_STREAM", "false") };
1328 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1329
1330 let mut settings = Settings::default();
1331 settings.apply_env();
1332 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1336
1337 #[test]
1338 fn test_apply_env_temperature() {
1339 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1341 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1342
1343 let mut settings = Settings::default();
1344 settings.apply_env();
1345 assert_eq!(settings.default_temperature, None);
1347 }
1348
1349 #[test]
1350 fn test_env_does_not_override_when_unset() {
1351 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1352 let settings = Settings::from_env();
1353 assert!(settings.last_used_model.is_none());
1354 assert!(settings.last_used_provider.is_none());
1355 }
1356
1357 #[test]
1358 fn test_parse_thinking_level() {
1359 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1360 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1361 assert_eq!(
1362 parse_thinking_level("MINIMAL"),
1363 Some(ThinkingLevel::Minimal)
1364 );
1365 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1366 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1367 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1368 assert_eq!(
1369 parse_thinking_level("Standard"),
1370 Some(ThinkingLevel::Medium)
1371 );
1372 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1373 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1374 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1375 assert_eq!(parse_thinking_level("invalid"), None);
1376 }
1377
1378 #[test]
1379 fn test_parse_boolish() {
1380 assert!(parse_boolish("true").unwrap());
1381 assert!(parse_boolish("1").unwrap());
1382 assert!(parse_boolish("yes").unwrap());
1383 assert!(parse_boolish("ON").unwrap());
1384 assert!(!parse_boolish("false").unwrap());
1385 assert!(!parse_boolish("0").unwrap());
1386 assert!(!parse_boolish("no").unwrap());
1387 assert!(!parse_boolish("OFF").unwrap());
1388 assert!(parse_boolish("maybe").is_err());
1389 }
1390
1391 #[test]
1394 fn test_effective_model_returns_last_used() {
1395 let mut settings = Settings::default();
1396 settings.last_used_model = Some("openai/gpt-4o".to_string());
1397 assert_eq!(
1398 settings.effective_model(None),
1399 Some("openai/gpt-4o".to_string())
1400 );
1401 }
1402
1403 #[test]
1404 fn test_effective_model_cli_overrides() {
1405 let mut settings = Settings::default();
1406 settings.last_used_model = Some("openai/gpt-4o".to_string());
1407 assert_eq!(
1408 settings.effective_model(Some("anthropic/claude-3")),
1409 Some("anthropic/claude-3".to_string())
1410 );
1411 }
1412
1413 #[test]
1414 fn test_effective_model_none_when_unset() {
1415 let settings = Settings::default();
1416 assert_eq!(settings.effective_model(None), None);
1417 }
1418
1419 #[test]
1420 fn test_effective_model_falls_back_to_last_used() {
1421 let mut settings = Settings::default();
1422 settings.last_used_model = Some("anthropic/claude-3".to_string());
1423 assert_eq!(
1424 settings.effective_model(None),
1425 Some("anthropic/claude-3".to_string())
1426 );
1427 }
1428
1429 #[test]
1430 fn test_effective_model_returns_none_when_nothing_set() {
1431 let settings = Settings::default();
1432 assert_eq!(settings.effective_model(None), None);
1433 }
1434
1435 #[test]
1436 fn test_effective_temperature_prefers_f64() {
1437 let mut settings = Settings::default();
1438 settings.temperature = Some(0.5);
1439 settings.default_temperature = Some(0.7);
1440 assert_eq!(settings.effective_temperature(), Some(0.7));
1441 }
1442
1443 #[test]
1444 fn test_effective_temperature_falls_back_to_f32() {
1445 let mut settings = Settings::default();
1446 settings.temperature = Some(0.5);
1447 assert_eq!(settings.effective_temperature(), Some(0.5));
1448 }
1449
1450 #[test]
1451 fn test_effective_max_tokens_prefers_usize() {
1452 let mut settings = Settings::default();
1453 settings.max_tokens = Some(1024);
1454 settings.max_response_tokens = Some(4096);
1455 assert_eq!(settings.effective_max_tokens(), Some(4096));
1456 }
1457
1458 #[test]
1459 fn test_effective_max_tokens_falls_back_to_u32() {
1460 let mut settings = Settings::default();
1461 settings.max_tokens = Some(1024);
1462 assert_eq!(settings.effective_max_tokens(), Some(1024));
1463 }
1464
1465 #[test]
1468 fn test_effective_session_dir_default() {
1469 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1470 let settings = Settings::default();
1471 let dir = settings.effective_session_dir().unwrap();
1472 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1473 }
1474
1475 #[test]
1476 fn test_effective_session_dir_from_field() {
1477 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1478 let mut settings = Settings::default();
1479 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1480 assert_eq!(
1481 settings.effective_session_dir().unwrap(),
1482 PathBuf::from("/tmp/oxi-sessions")
1483 );
1484 }
1485
1486 #[test]
1487 fn test_effective_session_dir_env_disabled() {
1488 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1491 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1492 let settings = Settings::default();
1493 let dir = settings.effective_session_dir().unwrap();
1495 assert!(
1496 dir.ends_with("sessions"),
1497 "expected default sessions dir, got: {:?}",
1498 dir
1499 );
1500 }
1501
1502 #[test]
1505 fn test_migration_v0_to_v1() {
1506 let mut settings = Settings::default();
1507 settings.version = 0;
1508 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1511 assert_eq!(migrated.version, SETTINGS_VERSION);
1512 assert_eq!(migrated.tool_timeout_seconds, 120);
1513 }
1514
1515 #[test]
1516 fn test_migration_already_current() {
1517 let settings = Settings::default();
1518 let migrated = Settings::migrate(settings).unwrap();
1519 assert_eq!(migrated.version, SETTINGS_VERSION);
1520 }
1521
1522 #[test]
1523 fn test_migration_v3_to_v4_splits_model() {
1524 let mut settings = Settings::default();
1525 settings.version = 3;
1526 settings.default_model = Some("openai/gpt-4o".to_string());
1527 settings.default_provider = None;
1528
1529 let migrated = Settings::migrate(settings).unwrap();
1530 assert_eq!(migrated.version, SETTINGS_VERSION);
1531 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1532 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1533 }
1534
1535 #[test]
1536 fn test_migration_v3_no_slash_keeps_model() {
1537 let mut settings = Settings::default();
1538 settings.version = 3;
1539 settings.default_model = Some("bare-model-name".to_string());
1540
1541 let migrated = Settings::migrate(settings).unwrap();
1542 assert_eq!(migrated.version, SETTINGS_VERSION);
1543 assert_eq!(
1544 migrated.last_used_model,
1545 Some("bare-model-name".to_string())
1546 );
1547 }
1548
1549 #[test]
1550 fn test_migration_future_version_fails() {
1551 let mut settings = Settings::default();
1552 settings.version = 9999;
1553 assert!(Settings::migrate(settings).is_err());
1554 }
1555
1556 #[test]
1559 fn test_default_output_languages_is_empty() {
1560 let settings = Settings::default();
1561 assert!(
1562 settings.output_languages.is_empty(),
1563 "all channels should default to auto (empty map)"
1564 );
1565 }
1566
1567 #[test]
1568 fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1569 let mut settings = Settings::default();
1570 settings.version = 4;
1571 settings
1572 .output_languages
1573 .insert("response".to_string(), "ko".to_string());
1574 settings
1575 .output_languages
1576 .insert("commit_message".to_string(), "en".to_string());
1577
1578 let migrated = Settings::migrate(settings).unwrap();
1579 assert_eq!(migrated.version, SETTINGS_VERSION);
1580 assert_eq!(
1581 migrated.output_languages.get("response"),
1582 Some(&"ko".to_string())
1583 );
1584 assert_eq!(
1585 migrated.output_languages.get("commit_message"),
1586 Some(&"en".to_string())
1587 );
1588 }
1589
1590 #[test]
1591 fn test_migration_v4_to_v5_creates_empty_if_missing() {
1592 let mut settings = Settings::default();
1596 settings.version = 4;
1597 assert!(settings.output_languages.is_empty());
1598
1599 let migrated = Settings::migrate(settings).unwrap();
1600 assert_eq!(migrated.version, SETTINGS_VERSION);
1601 assert!(migrated.output_languages.is_empty());
1602 }
1603
1604 #[test]
1605 fn test_validate_keeps_user_defined_channel() {
1606 let mut settings = Settings::default();
1611 settings
1612 .output_languages
1613 .insert("pr_description".to_string(), "en".to_string()); settings
1615 .output_languages
1616 .insert("response".to_string(), "ko".to_string()); settings.validate_output_languages();
1619
1620 assert!(settings.output_languages.contains_key("pr_description"));
1621 assert!(settings.output_languages.contains_key("response"));
1622 assert_eq!(
1623 settings.output_languages.get("pr_description"),
1624 Some(&"en".to_string())
1625 );
1626 assert_eq!(
1627 settings.output_languages.get("response"),
1628 Some(&"ko".to_string())
1629 );
1630 }
1631
1632 #[test]
1633 fn test_validate_keeps_unknown_lang_with_warning() {
1634 let mut settings = Settings::default();
1635 settings
1636 .output_languages
1637 .insert("response".to_string(), "klingon".to_string()); settings
1639 .output_languages
1640 .insert("commit_message".to_string(), "en".to_string()); settings.validate_output_languages();
1643
1644 assert_eq!(
1647 settings.output_languages.get("response"),
1648 Some(&"klingon".to_string())
1649 );
1650 assert_eq!(
1651 settings.output_languages.get("commit_message"),
1652 Some(&"en".to_string())
1653 );
1654 }
1655
1656 #[test]
1657 fn test_known_channels_table_includes_core_four() {
1658 let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1659 assert!(keys.contains(&"response"));
1660 assert!(keys.contains(&"code_comment"));
1661 assert!(keys.contains(&"documentation"));
1662 assert!(keys.contains(&"commit_message"));
1663 }
1664
1665 #[test]
1666 fn test_known_langs_table_includes_auto_and_english() {
1667 let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1668 assert!(codes.contains(&"auto"));
1669 assert!(codes.contains(&"en"));
1670 }
1671
1672 #[test]
1673 fn test_default_language_policy_enabled_is_false() {
1674 let settings = Settings::default();
1676 assert!(
1677 !settings.language_policy_enabled,
1678 "language_policy_enabled must default to false (opt-in)"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_migration_v5_to_v6_defaults_master_toggle_to_off() {
1684 let mut settings = Settings::default();
1688 settings.version = 5;
1689 settings
1690 .output_languages
1691 .insert("response".to_string(), "ko".to_string());
1692 settings
1693 .output_languages
1694 .insert("commit_message".to_string(), "en".to_string());
1695
1696 let migrated = Settings::migrate(settings).unwrap();
1697 assert_eq!(migrated.version, SETTINGS_VERSION);
1698 assert!(
1699 !migrated.language_policy_enabled,
1700 "v5 → v6 migration must default language_policy_enabled to false"
1701 );
1702 assert_eq!(
1704 migrated.output_languages.get("response"),
1705 Some(&"ko".to_string())
1706 );
1707 assert_eq!(
1708 migrated.output_languages.get("commit_message"),
1709 Some(&"en".to_string())
1710 );
1711 }
1712
1713 #[test]
1714 fn test_save_and_load_roundtrip_preserves_language_policy_enabled() {
1715 let tmp = tempfile::tempdir().unwrap();
1716 let settings_path = tmp.path().join("settings.toml");
1717
1718 let mut original = Settings::default();
1719 original.language_policy_enabled = true;
1720 original
1721 .output_languages
1722 .insert("response".to_string(), "ko".to_string());
1723
1724 let content = toml::to_string_pretty(&original).unwrap();
1725 fs::write(&settings_path, &content).unwrap();
1726
1727 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1728 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1729
1730 assert!(loaded.language_policy_enabled);
1731 assert_eq!(
1732 loaded.output_languages.get("response"),
1733 Some(&"ko".to_string())
1734 );
1735 }
1736
1737 #[test]
1738 fn test_save_and_load_roundtrip_preserves_output_languages() {
1739 let tmp = tempfile::tempdir().unwrap();
1740 let settings_path = tmp.path().join("settings.toml");
1741
1742 let mut original = Settings::default();
1743 original
1744 .output_languages
1745 .insert("response".to_string(), "ko".to_string());
1746 original
1747 .output_languages
1748 .insert("commit_message".to_string(), "en".to_string());
1749
1750 let content = toml::to_string_pretty(&original).unwrap();
1751 fs::write(&settings_path, &content).unwrap();
1752
1753 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1754 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1755
1756 assert_eq!(
1757 loaded.output_languages.get("response"),
1758 Some(&"ko".to_string())
1759 );
1760 assert_eq!(
1761 loaded.output_languages.get("commit_message"),
1762 Some(&"en".to_string())
1763 );
1764 }
1765
1766 #[test]
1769 fn test_save_and_load_roundtrip() {
1770 let tmp = tempfile::tempdir().unwrap();
1771 let settings_path = tmp.path().join("settings.toml");
1772
1773 let mut original = Settings::default();
1774 original.last_used_model = Some("gpt-4o".to_string());
1775 original.last_used_provider = Some("openai".to_string());
1776 original.theme = "dracula".to_string();
1777 original.tool_timeout_seconds = 60;
1778
1779 let content = toml::to_string_pretty(&original).unwrap();
1781 fs::write(&settings_path, &content).unwrap();
1782
1783 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1785 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1786
1787 assert_eq!(loaded.last_used_model, original.last_used_model);
1788 assert_eq!(loaded.theme, original.theme);
1789 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1790 }
1791
1792 #[test]
1793 fn test_toml_roundtrip_preserves_new_fields() {
1794 let mut settings = Settings::default();
1795 settings.default_temperature = Some(0.8);
1796 settings.max_response_tokens = Some(8192);
1797 settings.auto_compaction = false;
1798 settings.extensions_enabled = false;
1799 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1800
1801 let toml_str = toml::to_string_pretty(&settings).unwrap();
1802 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1803
1804 assert_eq!(parsed.default_temperature, Some(0.8));
1805 assert_eq!(parsed.max_response_tokens, Some(8192));
1806 assert!(!parsed.auto_compaction);
1807 assert!(!parsed.extensions_enabled);
1808 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1809 }
1810
1811 #[test]
1814 fn test_json_roundtrip() {
1815 let mut settings = Settings::default();
1816 settings.last_used_model = Some("gpt-4o".to_string());
1817 settings.last_used_provider = Some("openai".to_string());
1818 settings.theme = "dracula".to_string();
1819 settings.tool_timeout_seconds = 60;
1820 settings.default_temperature = Some(0.8);
1821 settings.max_response_tokens = Some(8192);
1822
1823 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1824 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1825
1826 assert_eq!(parsed.last_used_model, settings.last_used_model);
1827 assert_eq!(parsed.theme, settings.theme);
1828 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1829 assert_eq!(parsed.default_temperature, settings.default_temperature);
1830 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1831 }
1832
1833 #[test]
1834 fn test_json_serialize_for_format() {
1835 let mut settings = Settings::default();
1836 settings.last_used_model = Some("claude-3".to_string());
1837 settings.last_used_provider = Some("anthropic".to_string());
1838 settings.thinking_level = ThinkingLevel::Minimal;
1839
1840 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1841 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1842
1843 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1844 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1845 }
1846
1847 #[test]
1848 fn test_toml_serialize_for_format() {
1849 let mut settings = Settings::default();
1850 settings.last_used_model = Some("gemini-pro".to_string());
1851 settings.last_used_provider = Some("google".to_string());
1852 settings.thinking_level = ThinkingLevel::High;
1853
1854 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1855 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1856
1857 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1858 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1859 }
1860
1861 #[test]
1862 fn test_parse_from_str_json() {
1863 let json_content = r#"{
1864 "last_used_model": "gpt-4",
1865 "last_used_provider": "openai",
1866 "theme": "nord",
1867 "tool_timeout_seconds": 90
1868 }"#;
1869
1870 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1871 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1872 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1873 assert_eq!(settings.theme, "nord");
1874 assert_eq!(settings.tool_timeout_seconds, 90);
1875 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1877 assert!(settings.extensions_enabled);
1878 }
1879
1880 #[test]
1881 fn test_parse_from_str_toml() {
1882 let toml_content = r#"
1883last_used_model = "claude-opus"
1884last_used_provider = "anthropic"
1885theme = "monokai"
1886tool_timeout_seconds = 45
1887"#;
1888
1889 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1890 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1891 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1892 assert_eq!(settings.theme, "monokai");
1893 assert_eq!(settings.tool_timeout_seconds, 45);
1894 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1895 }
1896
1897 #[test]
1898 fn test_layer_file_json() {
1899 let base = Settings::default();
1900
1901 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1902 let json_content = r#"{
1903 "last_used_model": "gpt-4o",
1904 "last_used_provider": "openai",
1905 "theme": "dracula",
1906 "auto_compaction": false
1907 }"#;
1908 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1909
1910 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1911 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1912 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1913 assert_eq!(merged.theme, "dracula");
1914 assert!(!merged.auto_compaction);
1915 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1917 assert!(merged.extensions_enabled);
1918 assert_eq!(merged.tool_timeout_seconds, 120);
1919 }
1920
1921 #[test]
1922 fn test_layer_file_json_preserves_unset() {
1923 let mut base = Settings::default();
1924 base.last_used_provider = Some("deepseek".to_string());
1925
1926 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1927 let json_content = r#"{ "theme": "nord" }"#;
1928 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1929
1930 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1931 assert_eq!(merged.theme, "nord");
1932 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1933 }
1934
1935 #[test]
1936 fn test_save_to_json() {
1937 let tmp = tempfile::tempdir().unwrap();
1938 let settings_path = tmp.path().join("settings.json");
1939
1940 let mut settings = Settings::default();
1941 settings.last_used_model = Some("gpt-4o".to_string());
1942 settings.last_used_provider = Some("openai".to_string());
1943 settings.theme = "dracula".to_string();
1944 settings.tool_timeout_seconds = 60;
1945
1946 settings.save_to(&settings_path).unwrap();
1947
1948 let content = fs::read_to_string(&settings_path).unwrap();
1950 let parsed: Settings = serde_json::from_str(&content).unwrap();
1951 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1952 assert_eq!(parsed.theme, "dracula");
1953 assert_eq!(parsed.tool_timeout_seconds, 60);
1954 }
1955
1956 #[test]
1957 fn test_save_to_toml() {
1958 let tmp = tempfile::tempdir().unwrap();
1959 let settings_path = tmp.path().join("settings.toml");
1960
1961 let mut settings = Settings::default();
1962 settings.last_used_model = Some("gemini-pro".to_string());
1963 settings.last_used_provider = Some("google".to_string());
1964 settings.theme = "monokai".to_string();
1965 settings.tool_timeout_seconds = 90;
1966
1967 settings.save_to(&settings_path).unwrap();
1968
1969 let content = fs::read_to_string(&settings_path).unwrap();
1971 let parsed: Settings = toml::from_str(&content).unwrap();
1972 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1973 assert_eq!(parsed.theme, "monokai");
1974 assert_eq!(parsed.tool_timeout_seconds, 90);
1975 }
1976
1977 #[test]
1978 fn test_load_from_dir_with_json_project_config() {
1979 let _guard = EnvGuard::new(&[
1980 "OXI_MODEL",
1981 "OXI_PROVIDER",
1982 "OXI_THEME",
1983 "OXI_TOOL_TIMEOUT",
1984 "OXI_TEMPERATURE",
1985 "OXI_MAX_TOKENS",
1986 "OXI_SESSION_DIR",
1987 "OXI_STREAM",
1988 "OXI_EXTENSIONS_ENABLED",
1989 ]);
1990 let tmp = tempfile::tempdir().unwrap();
1991 let oxi_dir = tmp.path().join(".oxi");
1992 fs::create_dir_all(&oxi_dir).unwrap();
1993 let settings_path = oxi_dir.join("settings.json");
1994 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1996 fs::write(&settings_path, json_content).unwrap();
1997
1998 let settings = Settings::load_from(tmp.path()).unwrap();
1999 assert_eq!(
2001 settings.last_used_model,
2002 Some("gemini-2.0-flash".to_string())
2003 );
2004 assert_eq!(settings.last_used_provider, Some("google".to_string()));
2005 }
2006
2007 #[test]
2008 fn test_find_project_settings_json_priority() {
2009 let tmp = tempfile::tempdir().unwrap();
2010 let oxi_dir = tmp.path().join(".oxi");
2011 fs::create_dir_all(&oxi_dir).unwrap();
2012
2013 let json_path = oxi_dir.join("settings.json");
2015 let toml_path = oxi_dir.join("settings.toml");
2016 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2017 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2018
2019 let found = Settings::find_project_settings(tmp.path());
2021 assert!(found.is_some());
2022 assert_eq!(
2023 found.unwrap().file_name().unwrap().to_str().unwrap(),
2024 "settings.json"
2025 );
2026 }
2027
2028 #[test]
2029 fn test_find_project_settings_json_only() {
2030 let tmp = tempfile::tempdir().unwrap();
2031 let oxi_dir = tmp.path().join(".oxi");
2032 fs::create_dir_all(&oxi_dir).unwrap();
2033
2034 let json_path = oxi_dir.join("settings.json");
2035 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2036
2037 let found = Settings::find_project_settings(tmp.path());
2038 assert!(found.is_some());
2039 assert_eq!(
2040 found.unwrap().file_name().unwrap().to_str().unwrap(),
2041 "settings.json"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_find_project_settings_toml_fallback() {
2047 let tmp = tempfile::tempdir().unwrap();
2048 let oxi_dir = tmp.path().join(".oxi");
2049 fs::create_dir_all(&oxi_dir).unwrap();
2050
2051 let toml_path = oxi_dir.join("settings.toml");
2052 fs::write(&toml_path, r#"theme = "test""#).unwrap();
2053
2054 let found = Settings::find_project_settings(tmp.path());
2055 assert!(found.is_some());
2056 assert_eq!(
2057 found.unwrap().file_name().unwrap().to_str().unwrap(),
2058 "settings.toml"
2059 );
2060 }
2061
2062 #[test]
2063 fn test_detect_format() {
2064 let json_path = PathBuf::from("/test/settings.json");
2065 let toml_path = PathBuf::from("/test/settings.toml");
2066 let unknown_path = PathBuf::from("/test/settings");
2067
2068 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2069 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2070 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2071 }
2073
2074 #[test]
2075 fn test_settings_format_extension() {
2076 assert_eq!(SettingsFormat::Json.extension(), "json");
2077 assert_eq!(SettingsFormat::Toml.extension(), "toml");
2078 }
2079
2080 #[test]
2081 fn test_layer_json_over_toml() {
2082 let tmp = tempfile::tempdir().unwrap();
2084 let oxi_dir = tmp.path().join(".oxi");
2085 fs::create_dir_all(&oxi_dir).unwrap();
2086
2087 let json_path = oxi_dir.join("settings.json");
2088 let toml_path = oxi_dir.join("settings.toml");
2089
2090 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2092 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2094
2095 let settings = Settings::load_from(tmp.path()).unwrap();
2097 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2098 }
2099
2100 #[test]
2101 fn test_mixed_format_loading() {
2102 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2104 let toml_content = r#"
2105last_used_model = "loaded-via-toml"
2106theme = "loaded-theme"
2107stream_responses = false
2108"#;
2109 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2110
2111 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2112 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2113 assert_eq!(merged.theme, "loaded-theme");
2114 assert!(!merged.stream_responses);
2115 }
2116
2117 #[test]
2118 fn test_merge_json_values() {
2119 let base = serde_json::json!({
2120 "version": 1,
2121 "theme": "default",
2122 "extensions": ["ext1"],
2123 "nested": {
2124 "a": 1,
2125 "b": 2
2126 }
2127 });
2128
2129 let override_ = serde_json::json!({
2130 "version": 2,
2131 "theme": "dark",
2132 "extensions": ["ext2"],
2133 "nested": {
2134 "b": 20,
2135 "c": 30
2136 }
2137 });
2138
2139 let merged = merge_json_values(base, override_);
2140
2141 assert_eq!(merged["version"], 2);
2142 assert_eq!(merged["theme"], "dark");
2143 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2145 assert_eq!(merged["nested"]["a"], 1);
2147 assert_eq!(merged["nested"]["b"], 20);
2148 assert_eq!(merged["nested"]["c"], 30);
2149 }
2150
2151 #[test]
2152 fn test_save_project_preserves_existing_format() {
2153 let tmp = tempfile::tempdir().unwrap();
2154 let oxi_dir = tmp.path().join(".oxi");
2155 fs::create_dir_all(&oxi_dir).unwrap();
2156
2157 let toml_path = oxi_dir.join("settings.toml");
2159 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2160
2161 let mut settings = Settings::default();
2162 settings.theme = "new-theme".to_string();
2163 settings.save_project(tmp.path()).unwrap();
2164
2165 let content = fs::read_to_string(&toml_path).unwrap();
2167 assert!(content.contains("new-theme"));
2168 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2169 }
2170
2171 #[test]
2172 fn test_save_project_creates_json_by_default() {
2173 let tmp = tempfile::tempdir().unwrap();
2174 let oxi_dir = tmp.path().join(".oxi");
2175 fs::create_dir_all(&oxi_dir).unwrap();
2176 let mut settings = Settings::default();
2179 settings.theme = "json-theme".to_string();
2180 settings.save_project(tmp.path()).unwrap();
2181
2182 let json_path = oxi_dir.join("settings.json");
2184 assert!(json_path.exists());
2185 let content = fs::read_to_string(&json_path).unwrap();
2186 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2187 assert!(content.contains("json-theme"));
2188 }
2189
2190 #[test]
2193 fn test_custom_provider_default_api() {
2194 use super::CustomProvider;
2195 let cp = CustomProvider {
2196 name: "test".to_string(),
2197 base_url: "https://api.test.com/v1".to_string(),
2198 api_key_env: "TEST_API_KEY".to_string(),
2199 api: super::default_custom_provider_api(),
2200 };
2201 assert_eq!(cp.api, "openai-completions");
2202 }
2203
2204 #[test]
2205 fn test_custom_provider_toml_deserialize() {
2206 let toml_content = r#"
2207[[custom_providers]]
2208name = "minimax"
2209base_url = "https://api.minimax.chat/v1"
2210api_key_env = "MINIMAX_API_KEY"
2211api = "openai-completions"
2212
2213[[custom_providers]]
2214name = "zai"
2215base_url = "https://api.z.ai/v1"
2216api_key_env = "ZAI_API_KEY"
2217api = "openai-responses"
2218"#;
2219 let settings: Settings = toml::from_str(toml_content).unwrap();
2220 assert_eq!(settings.custom_providers.len(), 2);
2221 assert_eq!(settings.custom_providers[0].name, "minimax");
2222 assert_eq!(
2223 settings.custom_providers[0].base_url,
2224 "https://api.minimax.chat/v1"
2225 );
2226 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2227 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2228 assert_eq!(settings.custom_providers[1].name, "zai");
2229 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2230 }
2231
2232 #[test]
2233 fn test_custom_provider_json_deserialize() {
2234 let json_content = r#"{
2235 "custom_providers": [
2236 {
2237 "name": "minimax",
2238 "base_url": "https://api.minimax.chat/v1",
2239 "api_key_env": "MINIMAX_API_KEY",
2240 "api": "openai-completions"
2241 }
2242 ]
2243 }"#;
2244 let settings: Settings = serde_json::from_str(json_content).unwrap();
2245 assert_eq!(settings.custom_providers.len(), 1);
2246 assert_eq!(settings.custom_providers[0].name, "minimax");
2247 }
2248
2249 #[test]
2250 fn test_custom_provider_toml_roundtrip() {
2251 let mut settings = Settings::default();
2252 settings.custom_providers.push(super::CustomProvider {
2253 name: "test".to_string(),
2254 base_url: "https://api.test.com/v1".to_string(),
2255 api_key_env: "TEST_API_KEY".to_string(),
2256 api: "openai-completions".to_string(),
2257 });
2258
2259 let toml_str = toml::to_string_pretty(&settings).unwrap();
2260 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2261 assert_eq!(parsed.custom_providers.len(), 1);
2262 assert_eq!(parsed.custom_providers[0].name, "test");
2263 assert_eq!(
2264 parsed.custom_providers[0].base_url,
2265 "https://api.test.com/v1"
2266 );
2267 }
2268
2269 #[test]
2270 fn test_custom_provider_defaults_empty() {
2271 let settings = Settings::default();
2272 assert!(settings.custom_providers.is_empty());
2273 }
2274
2275 #[test]
2276 fn test_custom_provider_layer_file() {
2277 let base = Settings::default();
2278
2279 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2280 let toml_content = r#"
2281[[custom_providers]]
2282name = "my-provider"
2283base_url = "https://api.my-provider.com/v1"
2284api_key_env = "MY_PROVIDER_API_KEY"
2285"#;
2286 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2287
2288 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2289 assert_eq!(merged.custom_providers.len(), 1);
2290 assert_eq!(merged.custom_providers[0].name, "my-provider");
2291 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2293 }
2294}