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 = 7;
27
28pub const KNOWN_CHANNELS: &[(&str, &str)] = &[
39 ("response", "Your conversational responses to the user"),
40 (
41 "code_comment",
42 "Code comments you write (//, /* */, #, etc.)",
43 ),
44 (
45 "documentation",
46 "Documentation (markdown files, README, AGENTS.md, doc comments)",
47 ),
48 ("commit_message", "Git commit messages (subject + body)"),
49];
50
51pub const KNOWN_LANGS: &[(&str, &str)] = &[
62 ("auto", "Auto (match user)"),
63 ("en", "English"),
64 ("ko", "Korean (한국어)"),
65 ("ja", "Japanese (日本語)"),
66 ("zh", "Chinese (中文)"),
67 ("es", "Spanish"),
68 ("fr", "French"),
69 ("de", "German"),
70];
71
72#[allow(dead_code)]
75const ENV_PREFIX: &str = "OXI_";
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
79#[serde(rename_all = "snake_case")]
80pub enum ThinkingLevel {
81 #[default]
83 Off,
84 Minimal,
86 Low,
88 Medium,
90 High,
92 XHigh,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
102#[serde(rename_all = "snake_case")]
103pub enum EditFormat {
104 #[default]
106 Hashline,
107 StrReplace,
109}
110#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct CustomProvider {
116 pub name: String,
118 pub base_url: String,
120 pub api_key_env: String,
122 #[serde(default = "default_custom_provider_api")]
124 pub api: String,
125}
126
127fn default_custom_provider_api() -> String {
128 "openai-completions".to_string()
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct Settings {
134 #[serde(default)]
137 pub version: u32,
138
139 #[serde(default = "default_thinking_level")]
142 pub thinking_level: ThinkingLevel,
143
144 #[serde(default = "default_theme")]
146 pub theme: String,
147
148 #[serde(default, skip_serializing)]
150 pub default_model: Option<String>,
151
152 #[serde(default, skip_serializing)]
154 pub default_provider: Option<String>,
155
156 #[serde(default)]
159 pub last_used_model: Option<String>,
160
161 #[serde(default)]
163 pub last_used_provider: Option<String>,
164
165 pub max_tokens: Option<u32>,
167
168 pub temperature: Option<f32>,
170
171 pub default_temperature: Option<f64>,
173
174 pub max_response_tokens: Option<usize>,
176
177 #[serde(default = "default_session_history_size")]
180 pub session_history_size: usize,
181
182 pub session_dir: Option<PathBuf>,
184
185 #[serde(default = "default_true")]
188 pub stream_responses: bool,
189
190 #[serde(default = "default_true")]
192 pub extensions_enabled: bool,
193
194 #[serde(default = "default_true")]
196 pub auto_compaction: bool,
197
198 #[serde(default)]
201 pub disabled_tools: Vec<String>,
202
203 #[serde(default = "default_tool_timeout")]
206 pub tool_timeout_seconds: u64,
207
208 #[serde(default)]
211 pub questionnaire_timeout_secs: u64,
212
213 #[serde(default)]
216 pub extensions: Vec<String>,
217
218 #[serde(default)]
220 pub skills: Vec<String>,
221
222 #[serde(default)]
224 pub prompts: Vec<String>,
225
226 #[serde(default)]
228 pub themes: Vec<String>,
229
230 #[serde(default)]
233 pub custom_providers: Vec<CustomProvider>,
234
235 #[serde(default)]
240 pub dynamic_models: HashMap<String, Vec<String>>,
241
242 #[serde(default = "default_false")]
245 pub enable_routing: bool,
246
247 #[serde(default)]
249 pub router_profile: Option<String>,
250
251 #[serde(default = "default_true")]
253 pub prefer_cost_efficient: bool,
254
255 #[serde(default)]
257 pub fallback_chain: Vec<String>,
258
259 #[serde(default = "default_true")]
261 pub enable_fallback: bool,
262
263 #[serde(default)]
265 pub disable_fallback: bool,
266
267 #[serde(default = "default_circuit_failure_threshold")]
269 pub circuit_breaker_failure_threshold: u32,
270
271 #[serde(default = "default_circuit_open_duration_secs")]
273 pub circuit_breaker_open_duration_secs: u64,
274
275 #[serde(default)]
280 pub keybindings: HashMap<String, Vec<String>>,
281
282 #[serde(default)]
328 pub output_languages: HashMap<String, String>,
329
330 #[serde(default = "default_false")]
349 pub language_policy_enabled: bool,
350
351 #[serde(default)]
356 pub edit_format: EditFormat,
357
358 #[serde(default = "default_false")]
362 pub memory_enabled: bool,
363
364 #[serde(default)]
367 pub memory_db_path: Option<PathBuf>,
368
369 #[serde(default = "default_false")]
373 pub ttsr_enabled: bool,
374
375 #[serde(default = "default_ttsr_mode")]
377 pub ttsr_interrupt_mode: String,
378}
379
380fn default_theme() -> String {
381 "default".to_string()
382}
383
384fn default_thinking_level() -> ThinkingLevel {
385 ThinkingLevel::Medium
386}
387
388fn default_session_history_size() -> usize {
389 100
390}
391
392fn default_true() -> bool {
393 true
394}
395
396fn default_false() -> bool {
397 false
398}
399
400fn default_ttsr_mode() -> String {
401 "prose_only".to_string()
402}
403
404fn default_circuit_failure_threshold() -> u32 {
405 5
406}
407
408fn default_circuit_open_duration_secs() -> u64 {
409 30
410}
411
412fn default_tool_timeout() -> u64 {
413 120
414}
415
416impl Default for Settings {
417 fn default() -> Self {
418 Self {
419 version: SETTINGS_VERSION,
420 thinking_level: ThinkingLevel::Medium,
421 theme: default_theme(),
422 last_used_model: None,
423 last_used_provider: None,
424 default_model: None,
425 default_provider: None,
426 max_tokens: None,
427 temperature: None,
428 default_temperature: None,
429 max_response_tokens: None,
430 session_history_size: default_session_history_size(),
431 session_dir: None,
432 stream_responses: true,
433 extensions_enabled: true,
434 auto_compaction: true,
435 disabled_tools: Vec::new(),
436 tool_timeout_seconds: default_tool_timeout(),
437 questionnaire_timeout_secs: 0,
438 extensions: Vec::new(),
439 skills: Vec::new(),
440 prompts: Vec::new(),
441 themes: Vec::new(),
442 custom_providers: Vec::new(),
443 dynamic_models: HashMap::new(),
444 enable_routing: false,
446 router_profile: None,
447 prefer_cost_efficient: true,
448 fallback_chain: Vec::new(),
449 enable_fallback: true,
450 disable_fallback: false,
451 circuit_breaker_failure_threshold: 5,
452 circuit_breaker_open_duration_secs: 30,
453 keybindings: HashMap::new(),
454 output_languages: HashMap::new(),
455 language_policy_enabled: false,
456 edit_format: EditFormat::default(),
457 memory_enabled: false,
458 memory_db_path: None,
459 ttsr_enabled: false,
460 ttsr_interrupt_mode: default_ttsr_mode(),
461 }
462 }
463}
464
465impl Settings {
466 pub fn settings_dir() -> Result<PathBuf> {
470 let base = dirs::home_dir().context("Cannot determine home directory")?;
471 Ok(base.join(".oxi"))
472 }
473
474 pub fn settings_toml_path() -> Result<PathBuf> {
476 Ok(Self::settings_dir()?.join("settings.toml"))
477 }
478
479 pub fn settings_json_path() -> Result<PathBuf> {
481 Ok(Self::settings_dir()?.join("settings.json"))
482 }
483
484 pub fn settings_path() -> Result<PathBuf> {
491 let json_path = Self::settings_json_path()?;
492 let toml_path = Self::settings_toml_path()?;
493
494 if json_path.exists() && toml_path.exists() {
495 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
497 return Ok(json_path);
498 }
499
500 if json_path.exists() {
501 return Ok(json_path);
502 }
503
504 if toml_path.exists() {
505 return Ok(toml_path);
506 }
507
508 Ok(json_path)
510 }
511
512 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
517 let json_path = Self::settings_json_path()?;
518 let toml_path = Self::settings_toml_path()?;
519
520 let (primary, secondary) = if prefer_json {
521 (&json_path, &toml_path)
522 } else {
523 (&toml_path, &json_path)
524 };
525
526 if primary.exists() {
527 return Ok(primary.clone());
528 }
529
530 if secondary.exists() {
531 return Ok(secondary.clone());
532 }
533
534 Ok(primary.clone())
536 }
537
538 pub fn detect_format(path: &Path) -> SettingsFormat {
540 match path.extension().and_then(|e| e.to_str()) {
541 Some("json") => SettingsFormat::Json,
542 Some("toml") => SettingsFormat::Toml,
543 _ => SettingsFormat::Json, }
545 }
546
547 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
552 let mut dir = start_dir.to_path_buf();
553 loop {
554 let json_candidate = dir.join(".oxi").join("settings.json");
556 if json_candidate.exists() {
557 return Some(json_candidate);
558 }
559
560 let toml_candidate = dir.join(".oxi").join("settings.toml");
561 if toml_candidate.exists() {
562 return Some(toml_candidate);
563 }
564
565 if !dir.pop() {
566 return None;
567 }
568 }
569 }
570
571 pub fn effective_session_dir(&self) -> Result<PathBuf> {
575 if let Some(ref dir) = self.session_dir {
576 return Ok(dir.clone());
577 }
578 Ok(Self::settings_dir()?.join("sessions"))
579 }
580
581 pub fn load() -> Result<Self> {
599 Self::load_from_cwd()
600 }
601
602 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
604 let mut settings = Settings::default();
606
607 if let Ok(global_path) = Self::settings_path()
609 && global_path.exists()
610 {
611 settings = Self::layer_file(&settings, &global_path)?;
612 }
613
614 if let Some(project_path) = Self::find_project_settings(dir) {
616 settings = Self::layer_file(&settings, &project_path)?;
617 }
618
619 settings.apply_env();
621
622 settings = Self::migrate(settings)?;
624
625 settings.validate_output_languages();
627
628 Ok(settings)
629 }
630
631 fn validate_output_languages(&mut self) {
638 if self.output_languages.is_empty() {
639 return;
640 }
641 let known_langs: std::collections::HashSet<&str> =
642 KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
643
644 for (channel, lang) in &self.output_languages {
645 if !known_langs.contains(lang.as_str()) {
646 tracing::warn!(
647 "Unknown output_languages language code '{}' for channel '{}'. \
648 Keeping as-is (the model will likely understand).",
649 lang,
650 channel
651 );
652 }
653 }
654 }
655
656 pub fn load_from_cwd() -> Result<Self> {
658 let cwd = env::current_dir().context("Cannot determine current directory")?;
659 Self::load_from(&cwd)
660 }
661
662 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
668 let content = fs::read_to_string(path)
669 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
670
671 let format = Self::detect_format(path);
672 let overlay: serde_json::Value = match format {
673 SettingsFormat::Toml => {
674 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
675 format!("Failed to parse TOML settings from {}", path.display())
676 })?;
677 toml_value_to_json(toml_value)
679 }
680 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
681 format!("Failed to parse JSON settings from {}", path.display())
682 })?,
683 };
684
685 let base_json =
689 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
690
691 let merged = merge_json_values(base_json, overlay);
692 let result: Settings =
693 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
694
695 Ok(result)
696 }
697
698 #[allow(dead_code)]
724 pub fn apply_env(&mut self) {
725 }
729
730 #[allow(dead_code)]
736 pub fn from_env() -> Self {
737 Self::default()
738 }
739
740 pub fn save(&self) -> Result<()> {
747 let dir = Self::settings_dir()?;
748 let path = Self::settings_path()?;
749
750 if !dir.exists() {
751 fs::create_dir_all(&dir).with_context(|| {
752 format!("Failed to create settings directory {}", dir.display())
753 })?;
754 }
755
756 let format = Self::detect_format(&path);
757 let content = Self::serialize_for_format(self, format)?;
758
759 let tmp_path = path.with_extension("tmp");
761 fs::write(&tmp_path, &content)
762 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
763 fs::rename(&tmp_path, &path)
764 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
765
766 Ok(())
767 }
768
769 pub fn save_to(&self, path: &Path) -> Result<()> {
771 if let Some(parent) = path.parent()
772 && !parent.exists()
773 {
774 fs::create_dir_all(parent)
775 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
776 }
777
778 let format = Self::detect_format(path);
779 let content = Self::serialize_for_format(self, format)?;
780
781 let tmp_path = path.with_extension("tmp");
783 fs::write(&tmp_path, &content)
784 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
785 fs::rename(&tmp_path, path)
786 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
787
788 Ok(())
789 }
790
791 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
795 let dir = project_dir.join(".oxi");
796
797 if !dir.exists() {
798 fs::create_dir_all(&dir).with_context(|| {
799 format!(
800 "Failed to create project settings directory {}",
801 dir.display()
802 )
803 })?;
804 }
805
806 let json_path = dir.join("settings.json");
808 let toml_path = dir.join("settings.toml");
809
810 let path = if json_path.exists() {
811 &json_path
812 } else if toml_path.exists() {
813 &toml_path
814 } else {
815 &json_path
817 };
818
819 let format = Self::detect_format(path);
820 let content = Self::serialize_for_format(self, format)?;
821
822 let tmp_path = path.with_extension("tmp");
824 fs::write(&tmp_path, &content)
825 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
826 fs::rename(&tmp_path, path)
827 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
828
829 Ok(())
830 }
831
832 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
834 match format {
835 SettingsFormat::Toml => {
836 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
837 }
838 SettingsFormat::Json => serde_json::to_string_pretty(settings)
839 .context("Failed to serialize settings to JSON"),
840 }
841 }
842
843 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
845 match format {
846 SettingsFormat::Toml => {
847 toml::from_str(content).context("Failed to parse TOML settings")
848 }
849 SettingsFormat::Json => {
850 serde_json::from_str(content).context("Failed to parse JSON settings")
851 }
852 }
853 }
854
855 pub fn merge_cli(
868 &mut self,
869 model: Option<String>,
870 provider: Option<String>,
871 enable_routing: Option<bool>,
872 prefer_cost_efficient: Option<bool>,
873 fallback_chain: Option<Vec<String>>,
874 disable_fallback: Option<bool>,
875 ) {
876 if let Some(m) = model {
877 self.last_used_model = Some(m);
878 }
879 if let Some(p) = provider {
880 self.last_used_provider = Some(p);
881 }
882 if let Some(r) = enable_routing {
883 self.enable_routing = r;
884 }
885 if let Some(p) = prefer_cost_efficient {
886 self.prefer_cost_efficient = p;
887 }
888 if let Some(fc) = fallback_chain
889 && !fc.is_empty()
890 {
891 self.fallback_chain = fc;
892 }
893 if let Some(df) = disable_fallback {
894 self.disable_fallback = df;
895 if df {
897 self.enable_fallback = false;
898 }
899 }
900 }
901
902 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
905 cli_model.map(String::from).or_else(|| {
906 let model = self.last_used_model.as_ref()?;
911 if model.contains('/') {
912 Some(model.clone())
914 } else if let Some(ref provider) = self.last_used_provider {
915 Some(format!("{}/{}", provider, model))
917 } else {
918 Some(model.clone())
919 }
920 })
921 }
922
923 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
926 cli_provider
927 .map(String::from)
928 .or_else(|| self.last_used_provider.clone())
929 }
930
931 pub fn effective_temperature(&self) -> Option<f64> {
934 self.default_temperature
935 .or(self.temperature.map(|t| t as f64))
936 }
937
938 pub fn effective_max_tokens(&self) -> Option<usize> {
941 self.max_response_tokens
942 .or(self.max_tokens.map(|t| t as usize))
943 }
944
945 pub fn router_profile(&self) -> Option<&str> {
947 self.router_profile.as_deref()
948 }
949
950 pub fn save_last_used(model_id: &str) {
956 if let Ok(mut settings) = Self::load() {
957 if let Some((provider, model)) = model_id.split_once('/') {
958 settings.last_used_provider = Some(provider.to_string());
959 settings.last_used_model = Some(model.to_string());
960 } else {
961 settings.last_used_model = Some(model_id.to_string());
962 }
963 let _ = settings.save();
964 }
965 }
966
967 pub fn save_theme(&mut self, name: &str) -> Result<()> {
969 self.theme = name.to_string();
970 self.save()
971 }
972
973 pub fn get_theme_name(&self) -> String {
975 if self.theme.is_empty() || self.theme == "default" {
976 "oxi_dark".to_string()
977 } else {
978 self.theme.clone()
979 }
980 }
981
982 fn migrate(settings: Settings) -> Result<Settings> {
996 let mut settings = settings;
997
998 match settings.version {
999 SETTINGS_VERSION => {
1000 }
1002 0 => {
1003 if settings.tool_timeout_seconds == 0 {
1006 settings.tool_timeout_seconds = default_tool_timeout();
1007 }
1008 settings.version = SETTINGS_VERSION;
1009
1010 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1011 }
1012 1 | 2 => {
1013 settings.version = SETTINGS_VERSION;
1018 tracing::info!(
1019 "Migrated settings from version {} to {} (dynamic_models + output_languages + language_policy_enabled defaults applied)",
1020 settings.version,
1021 SETTINGS_VERSION
1022 );
1023 }
1024 3 => {
1025 if let Some(model) = settings.default_model.take() {
1027 if let Some((provider, model_name)) = model.split_once('/') {
1028 settings.last_used_provider = Some(provider.to_string());
1029 settings.last_used_model = Some(model_name.to_string());
1030 } else {
1031 settings.last_used_model = Some(model);
1032 }
1033 }
1034 settings.version = SETTINGS_VERSION;
1036 tracing::info!(
1037 "Migrated settings from version 3 to {} (default_model → last_used_model; output_languages + language_policy_enabled defaults)",
1038 SETTINGS_VERSION
1039 );
1040 }
1041 4 => {
1042 settings.version = SETTINGS_VERSION;
1046 tracing::info!(
1047 "Migrated settings from version 4 to {} (added output_languages + language_policy_enabled, both defaulted to off)",
1048 SETTINGS_VERSION
1049 );
1050 }
1051 5 => {
1052 settings.version = SETTINGS_VERSION;
1058 tracing::info!(
1059 "Migrated settings from version 5 to {} (added language_policy_enabled, defaulting to OFF — toggle ON in /settings to activate existing channels)",
1060 SETTINGS_VERSION
1061 );
1062 }
1063 6 => {
1064 settings.version = SETTINGS_VERSION;
1067 tracing::info!(
1068 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1069 SETTINGS_VERSION
1070 );
1071 }
1072 v if v > SETTINGS_VERSION => {
1073 anyhow::bail!(
1075 "Settings version {} is newer than supported version {}. \
1076 Please update oxi.",
1077 v,
1078 SETTINGS_VERSION
1079 );
1080 }
1081 v => {
1082 tracing::warn!(
1084 "Unknown settings version {}, attempting migration to {}",
1085 v,
1086 SETTINGS_VERSION
1087 );
1088 settings.version = SETTINGS_VERSION;
1089 }
1090 }
1091
1092 Ok(settings)
1093 }
1094}
1095
1096#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1100pub enum SettingsFormat {
1101 #[default]
1103 Json,
1104 Toml,
1106}
1107
1108impl SettingsFormat {
1109 pub fn extension(&self) -> &'static str {
1111 match self {
1112 SettingsFormat::Json => "json",
1113 SettingsFormat::Toml => "toml",
1114 }
1115 }
1116}
1117
1118fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1122 match toml {
1123 toml::Value::String(s) => serde_json::Value::String(s),
1124 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1125 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1126 .map(serde_json::Value::Number)
1127 .unwrap_or(serde_json::Value::Null),
1128 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1129 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1130 toml::Value::Array(arr) => {
1131 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1132 }
1133 toml::Value::Table(table) => {
1134 let obj = table
1135 .into_iter()
1136 .map(|(k, v)| (k, toml_value_to_json(v)))
1137 .collect();
1138 serde_json::Value::Object(obj)
1139 }
1140 }
1141}
1142
1143fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1145 match (base, override_) {
1146 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1148 let mut result = base_map;
1149 for (key, override_value) in override_map {
1150 let base_value = result.remove(&key);
1151 let merged = match base_value {
1152 Some(base_v) => merge_json_values(base_v, override_value),
1153 None => override_value,
1154 };
1155 result.insert(key, merged);
1156 }
1157 serde_json::Value::Object(result)
1158 }
1159 (_, override_) => override_,
1161 }
1162}
1163
1164pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1166 match s.to_lowercase().as_str() {
1167 "off" | "none" => Some(ThinkingLevel::Off),
1168 "minimal" => Some(ThinkingLevel::Minimal),
1169 "low" => Some(ThinkingLevel::Low),
1170 "medium" | "standard" => Some(ThinkingLevel::Medium),
1171 "high" | "thorough" => Some(ThinkingLevel::High),
1172 "xhigh" => Some(ThinkingLevel::XHigh),
1173 _ => None,
1174 }
1175}
1176
1177#[allow(dead_code)]
1179fn parse_boolish(s: &str) -> Result<bool> {
1180 match s.to_lowercase().as_str() {
1181 "true" | "1" | "yes" | "on" => Ok(true),
1182 "false" | "0" | "no" | "off" => Ok(false),
1183 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1184 }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190 use std::io::Write as IoWrite;
1191 use std::sync::Mutex;
1192
1193 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1196
1197 struct EnvGuard {
1200 saved: Vec<(String, Option<String>)>,
1201 }
1202
1203 impl EnvGuard {
1204 fn new(vars: &[&str]) -> Self {
1205 let saved = vars
1206 .iter()
1207 .map(|&name| {
1208 let old = env::var(name).ok();
1209 unsafe { env::remove_var(name) };
1211 (name.to_string(), old)
1212 })
1213 .collect();
1214 Self { saved }
1215 }
1216 }
1217
1218 impl Drop for EnvGuard {
1219 fn drop(&mut self) {
1220 for (name, old) in self.saved.drain(..) {
1221 match old {
1222 Some(val) => unsafe { env::set_var(&name, val) },
1224 None => unsafe { env::remove_var(&name) },
1225 }
1226 }
1227 }
1228 }
1229
1230 #[test]
1233 fn test_default_settings() {
1234 let settings = Settings::default();
1235 assert_eq!(settings.version, SETTINGS_VERSION);
1236 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1237 assert_eq!(settings.theme, "default");
1238 assert!(settings.last_used_model.is_none());
1239 assert!(settings.last_used_provider.is_none());
1240 assert!(settings.extensions_enabled);
1241 assert!(settings.auto_compaction);
1242 assert_eq!(settings.tool_timeout_seconds, 120);
1243 assert!(settings.stream_responses);
1244 }
1245
1246 #[test]
1247 fn test_merge_cli() {
1248 let mut settings = Settings::default();
1249 settings.last_used_model = Some("gpt-4o".to_string());
1250
1251 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1252 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1253
1254 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1255 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1256
1257 settings.merge_cli(
1259 None,
1260 None,
1261 Some(true),
1262 Some(false),
1263 Some(vec!["openai/gpt-4o".to_string()]),
1264 Some(false),
1265 );
1266 assert!(settings.enable_routing);
1267 assert!(!settings.prefer_cost_efficient);
1268 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1269 assert!(!settings.disable_fallback);
1270
1271 let mut settings2 = Settings::default();
1273 settings2.merge_cli(None, None, None, None, None, Some(true));
1274 assert!(settings2.disable_fallback);
1275 assert!(!settings2.enable_fallback);
1276 }
1277
1278 #[test]
1281 fn test_layer_file_overrides() {
1282 let base = Settings::default();
1283
1284 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1285 let toml_content = r#"
1286last_used_model = "openai/gpt-4o"
1287theme = "dracula"
1288"#;
1289 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1290
1291 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1292 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1293 assert_eq!(merged.theme, "dracula");
1294 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1296 assert!(merged.extensions_enabled);
1297 }
1298
1299 #[test]
1300 fn test_layer_file_preserves_unset() {
1301 let mut base = Settings::default();
1302 base.last_used_provider = Some("deepseek".to_string());
1303
1304 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1305 let toml_content = "theme = \"monokai\"\n";
1307 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1308
1309 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1310 assert_eq!(merged.theme, "monokai");
1311 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1312 }
1313
1314 #[test]
1315 fn test_load_from_dir_with_project_config() {
1316 let _guard = EnvGuard::new(&[
1317 "OXI_MODEL",
1318 "OXI_PROVIDER",
1319 "OXI_THEME",
1320 "OXI_TOOL_TIMEOUT",
1321 "OXI_TEMPERATURE",
1322 "OXI_MAX_TOKENS",
1323 "OXI_SESSION_DIR",
1324 "OXI_STREAM",
1325 "OXI_EXTENSIONS_ENABLED",
1326 ]);
1327 let tmp = tempfile::tempdir().unwrap();
1328 let oxi_dir = tmp.path().join(".oxi");
1329 fs::create_dir_all(&oxi_dir).unwrap();
1330 let settings_path = oxi_dir.join("settings.toml");
1331 fs::write(
1333 &settings_path,
1334 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1335 )
1336 .unwrap();
1337
1338 let settings = Settings::load_from(tmp.path()).unwrap();
1339 assert_eq!(
1341 settings.last_used_model,
1342 Some("gemini-2.0-flash".to_string())
1343 );
1344 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1345 }
1346
1347 #[test]
1348 fn test_load_from_dir_no_config() {
1349 let _guard = EnvGuard::new(&[
1351 "OXI_MODEL",
1352 "OXI_PROVIDER",
1353 "OXI_THEME",
1354 "OXI_TOOL_TIMEOUT",
1355 "OXI_TEMPERATURE",
1356 "OXI_MAX_TOKENS",
1357 "OXI_SESSION_DIR",
1358 "OXI_STREAM",
1359 "OXI_EXTENSIONS_ENABLED",
1360 ]);
1361 let tmp = tempfile::tempdir().unwrap();
1362 let settings = Settings::load_from(tmp.path()).unwrap();
1363 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1365 }
1366
1367 #[test]
1370 fn test_from_env() {
1371 let _guard = EnvGuard::new(&[
1374 "OXI_MODEL",
1376 "OXI_THEME",
1377 "OXI_TOOL_TIMEOUT",
1378 "OXI_PROVIDER",
1379 "OXI_DEFAULT_MODEL",
1380 ]);
1381
1382 let settings = Settings::from_env();
1383 assert_eq!(settings.last_used_model, None);
1385 assert_eq!(settings.theme, "default");
1386 assert_eq!(settings.tool_timeout_seconds, 120);
1387 }
1388
1389 #[test]
1390 fn test_apply_env_boolish() {
1391 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1394 unsafe { env::set_var("OXI_STREAM", "false") };
1395 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1396
1397 let mut settings = Settings::default();
1398 settings.apply_env();
1399 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1403
1404 #[test]
1405 fn test_apply_env_temperature() {
1406 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1408 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1409
1410 let mut settings = Settings::default();
1411 settings.apply_env();
1412 assert_eq!(settings.default_temperature, None);
1414 }
1415
1416 #[test]
1417 fn test_env_does_not_override_when_unset() {
1418 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1419 let settings = Settings::from_env();
1420 assert!(settings.last_used_model.is_none());
1421 assert!(settings.last_used_provider.is_none());
1422 }
1423
1424 #[test]
1425 fn test_parse_thinking_level() {
1426 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1427 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1428 assert_eq!(
1429 parse_thinking_level("MINIMAL"),
1430 Some(ThinkingLevel::Minimal)
1431 );
1432 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1433 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1434 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1435 assert_eq!(
1436 parse_thinking_level("Standard"),
1437 Some(ThinkingLevel::Medium)
1438 );
1439 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1440 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1441 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1442 assert_eq!(parse_thinking_level("invalid"), None);
1443 }
1444
1445 #[test]
1446 fn test_parse_boolish() {
1447 assert!(parse_boolish("true").unwrap());
1448 assert!(parse_boolish("1").unwrap());
1449 assert!(parse_boolish("yes").unwrap());
1450 assert!(parse_boolish("ON").unwrap());
1451 assert!(!parse_boolish("false").unwrap());
1452 assert!(!parse_boolish("0").unwrap());
1453 assert!(!parse_boolish("no").unwrap());
1454 assert!(!parse_boolish("OFF").unwrap());
1455 assert!(parse_boolish("maybe").is_err());
1456 }
1457
1458 #[test]
1461 fn test_effective_model_returns_last_used() {
1462 let mut settings = Settings::default();
1463 settings.last_used_model = Some("openai/gpt-4o".to_string());
1464 assert_eq!(
1465 settings.effective_model(None),
1466 Some("openai/gpt-4o".to_string())
1467 );
1468 }
1469
1470 #[test]
1471 fn test_effective_model_cli_overrides() {
1472 let mut settings = Settings::default();
1473 settings.last_used_model = Some("openai/gpt-4o".to_string());
1474 assert_eq!(
1475 settings.effective_model(Some("anthropic/claude-3")),
1476 Some("anthropic/claude-3".to_string())
1477 );
1478 }
1479
1480 #[test]
1481 fn test_effective_model_none_when_unset() {
1482 let settings = Settings::default();
1483 assert_eq!(settings.effective_model(None), None);
1484 }
1485
1486 #[test]
1487 fn test_effective_model_falls_back_to_last_used() {
1488 let mut settings = Settings::default();
1489 settings.last_used_model = Some("anthropic/claude-3".to_string());
1490 assert_eq!(
1491 settings.effective_model(None),
1492 Some("anthropic/claude-3".to_string())
1493 );
1494 }
1495
1496 #[test]
1497 fn test_effective_model_returns_none_when_nothing_set() {
1498 let settings = Settings::default();
1499 assert_eq!(settings.effective_model(None), None);
1500 }
1501
1502 #[test]
1503 fn test_effective_temperature_prefers_f64() {
1504 let mut settings = Settings::default();
1505 settings.temperature = Some(0.5);
1506 settings.default_temperature = Some(0.7);
1507 assert_eq!(settings.effective_temperature(), Some(0.7));
1508 }
1509
1510 #[test]
1511 fn test_effective_temperature_falls_back_to_f32() {
1512 let mut settings = Settings::default();
1513 settings.temperature = Some(0.5);
1514 assert_eq!(settings.effective_temperature(), Some(0.5));
1515 }
1516
1517 #[test]
1518 fn test_effective_max_tokens_prefers_usize() {
1519 let mut settings = Settings::default();
1520 settings.max_tokens = Some(1024);
1521 settings.max_response_tokens = Some(4096);
1522 assert_eq!(settings.effective_max_tokens(), Some(4096));
1523 }
1524
1525 #[test]
1526 fn test_effective_max_tokens_falls_back_to_u32() {
1527 let mut settings = Settings::default();
1528 settings.max_tokens = Some(1024);
1529 assert_eq!(settings.effective_max_tokens(), Some(1024));
1530 }
1531
1532 #[test]
1535 fn test_effective_session_dir_default() {
1536 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1537 let settings = Settings::default();
1538 let dir = settings.effective_session_dir().unwrap();
1539 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1540 }
1541
1542 #[test]
1543 fn test_effective_session_dir_from_field() {
1544 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1545 let mut settings = Settings::default();
1546 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1547 assert_eq!(
1548 settings.effective_session_dir().unwrap(),
1549 PathBuf::from("/tmp/oxi-sessions")
1550 );
1551 }
1552
1553 #[test]
1554 fn test_effective_session_dir_env_disabled() {
1555 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1558 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1559 let settings = Settings::default();
1560 let dir = settings.effective_session_dir().unwrap();
1562 assert!(
1563 dir.ends_with("sessions"),
1564 "expected default sessions dir, got: {:?}",
1565 dir
1566 );
1567 }
1568
1569 #[test]
1572 fn test_migration_v0_to_v1() {
1573 let mut settings = Settings::default();
1574 settings.version = 0;
1575 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1578 assert_eq!(migrated.version, SETTINGS_VERSION);
1579 assert_eq!(migrated.tool_timeout_seconds, 120);
1580 }
1581
1582 #[test]
1583 fn test_migration_already_current() {
1584 let settings = Settings::default();
1585 let migrated = Settings::migrate(settings).unwrap();
1586 assert_eq!(migrated.version, SETTINGS_VERSION);
1587 }
1588
1589 #[test]
1590 fn test_migration_v3_to_v4_splits_model() {
1591 let mut settings = Settings::default();
1592 settings.version = 3;
1593 settings.default_model = Some("openai/gpt-4o".to_string());
1594 settings.default_provider = None;
1595
1596 let migrated = Settings::migrate(settings).unwrap();
1597 assert_eq!(migrated.version, SETTINGS_VERSION);
1598 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1599 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1600 }
1601
1602 #[test]
1603 fn test_migration_v3_no_slash_keeps_model() {
1604 let mut settings = Settings::default();
1605 settings.version = 3;
1606 settings.default_model = Some("bare-model-name".to_string());
1607
1608 let migrated = Settings::migrate(settings).unwrap();
1609 assert_eq!(migrated.version, SETTINGS_VERSION);
1610 assert_eq!(
1611 migrated.last_used_model,
1612 Some("bare-model-name".to_string())
1613 );
1614 }
1615
1616 #[test]
1617 fn test_migration_future_version_fails() {
1618 let mut settings = Settings::default();
1619 settings.version = 9999;
1620 assert!(Settings::migrate(settings).is_err());
1621 }
1622
1623 #[test]
1626 fn test_default_output_languages_is_empty() {
1627 let settings = Settings::default();
1628 assert!(
1629 settings.output_languages.is_empty(),
1630 "all channels should default to auto (empty map)"
1631 );
1632 }
1633
1634 #[test]
1635 fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1636 let mut settings = Settings::default();
1637 settings.version = 4;
1638 settings
1639 .output_languages
1640 .insert("response".to_string(), "ko".to_string());
1641 settings
1642 .output_languages
1643 .insert("commit_message".to_string(), "en".to_string());
1644
1645 let migrated = Settings::migrate(settings).unwrap();
1646 assert_eq!(migrated.version, SETTINGS_VERSION);
1647 assert_eq!(
1648 migrated.output_languages.get("response"),
1649 Some(&"ko".to_string())
1650 );
1651 assert_eq!(
1652 migrated.output_languages.get("commit_message"),
1653 Some(&"en".to_string())
1654 );
1655 }
1656
1657 #[test]
1658 fn test_migration_v4_to_v5_creates_empty_if_missing() {
1659 let mut settings = Settings::default();
1663 settings.version = 4;
1664 assert!(settings.output_languages.is_empty());
1665
1666 let migrated = Settings::migrate(settings).unwrap();
1667 assert_eq!(migrated.version, SETTINGS_VERSION);
1668 assert!(migrated.output_languages.is_empty());
1669 }
1670
1671 #[test]
1672 fn test_validate_keeps_user_defined_channel() {
1673 let mut settings = Settings::default();
1678 settings
1679 .output_languages
1680 .insert("pr_description".to_string(), "en".to_string()); settings
1682 .output_languages
1683 .insert("response".to_string(), "ko".to_string()); settings.validate_output_languages();
1686
1687 assert!(settings.output_languages.contains_key("pr_description"));
1688 assert!(settings.output_languages.contains_key("response"));
1689 assert_eq!(
1690 settings.output_languages.get("pr_description"),
1691 Some(&"en".to_string())
1692 );
1693 assert_eq!(
1694 settings.output_languages.get("response"),
1695 Some(&"ko".to_string())
1696 );
1697 }
1698
1699 #[test]
1700 fn test_validate_keeps_unknown_lang_with_warning() {
1701 let mut settings = Settings::default();
1702 settings
1703 .output_languages
1704 .insert("response".to_string(), "klingon".to_string()); settings
1706 .output_languages
1707 .insert("commit_message".to_string(), "en".to_string()); settings.validate_output_languages();
1710
1711 assert_eq!(
1714 settings.output_languages.get("response"),
1715 Some(&"klingon".to_string())
1716 );
1717 assert_eq!(
1718 settings.output_languages.get("commit_message"),
1719 Some(&"en".to_string())
1720 );
1721 }
1722
1723 #[test]
1724 fn test_known_channels_table_includes_core_four() {
1725 let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1726 assert!(keys.contains(&"response"));
1727 assert!(keys.contains(&"code_comment"));
1728 assert!(keys.contains(&"documentation"));
1729 assert!(keys.contains(&"commit_message"));
1730 }
1731
1732 #[test]
1733 fn test_known_langs_table_includes_auto_and_english() {
1734 let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1735 assert!(codes.contains(&"auto"));
1736 assert!(codes.contains(&"en"));
1737 }
1738
1739 #[test]
1740 fn test_default_language_policy_enabled_is_false() {
1741 let settings = Settings::default();
1743 assert!(
1744 !settings.language_policy_enabled,
1745 "language_policy_enabled must default to false (opt-in)"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_migration_v5_to_v6_defaults_master_toggle_to_off() {
1751 let mut settings = Settings::default();
1755 settings.version = 5;
1756 settings
1757 .output_languages
1758 .insert("response".to_string(), "ko".to_string());
1759 settings
1760 .output_languages
1761 .insert("commit_message".to_string(), "en".to_string());
1762
1763 let migrated = Settings::migrate(settings).unwrap();
1764 assert_eq!(migrated.version, SETTINGS_VERSION);
1765 assert!(
1766 !migrated.language_policy_enabled,
1767 "v5 → v6 migration must default language_policy_enabled to false"
1768 );
1769 assert_eq!(
1771 migrated.output_languages.get("response"),
1772 Some(&"ko".to_string())
1773 );
1774 assert_eq!(
1775 migrated.output_languages.get("commit_message"),
1776 Some(&"en".to_string())
1777 );
1778 }
1779
1780 #[test]
1781 fn test_save_and_load_roundtrip_preserves_language_policy_enabled() {
1782 let tmp = tempfile::tempdir().unwrap();
1783 let settings_path = tmp.path().join("settings.toml");
1784
1785 let mut original = Settings::default();
1786 original.language_policy_enabled = true;
1787 original
1788 .output_languages
1789 .insert("response".to_string(), "ko".to_string());
1790
1791 let content = toml::to_string_pretty(&original).unwrap();
1792 fs::write(&settings_path, &content).unwrap();
1793
1794 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1795 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1796
1797 assert!(loaded.language_policy_enabled);
1798 assert_eq!(
1799 loaded.output_languages.get("response"),
1800 Some(&"ko".to_string())
1801 );
1802 }
1803
1804 #[test]
1805 fn test_save_and_load_roundtrip_preserves_output_languages() {
1806 let tmp = tempfile::tempdir().unwrap();
1807 let settings_path = tmp.path().join("settings.toml");
1808
1809 let mut original = Settings::default();
1810 original
1811 .output_languages
1812 .insert("response".to_string(), "ko".to_string());
1813 original
1814 .output_languages
1815 .insert("commit_message".to_string(), "en".to_string());
1816
1817 let content = toml::to_string_pretty(&original).unwrap();
1818 fs::write(&settings_path, &content).unwrap();
1819
1820 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1821 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1822
1823 assert_eq!(
1824 loaded.output_languages.get("response"),
1825 Some(&"ko".to_string())
1826 );
1827 assert_eq!(
1828 loaded.output_languages.get("commit_message"),
1829 Some(&"en".to_string())
1830 );
1831 }
1832
1833 #[test]
1836 fn test_save_and_load_roundtrip() {
1837 let tmp = tempfile::tempdir().unwrap();
1838 let settings_path = tmp.path().join("settings.toml");
1839
1840 let mut original = Settings::default();
1841 original.last_used_model = Some("gpt-4o".to_string());
1842 original.last_used_provider = Some("openai".to_string());
1843 original.theme = "dracula".to_string();
1844 original.tool_timeout_seconds = 60;
1845
1846 let content = toml::to_string_pretty(&original).unwrap();
1848 fs::write(&settings_path, &content).unwrap();
1849
1850 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1852 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1853
1854 assert_eq!(loaded.last_used_model, original.last_used_model);
1855 assert_eq!(loaded.theme, original.theme);
1856 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1857 }
1858
1859 #[test]
1860 fn test_toml_roundtrip_preserves_new_fields() {
1861 let mut settings = Settings::default();
1862 settings.default_temperature = Some(0.8);
1863 settings.max_response_tokens = Some(8192);
1864 settings.auto_compaction = false;
1865 settings.extensions_enabled = false;
1866 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1867
1868 let toml_str = toml::to_string_pretty(&settings).unwrap();
1869 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1870
1871 assert_eq!(parsed.default_temperature, Some(0.8));
1872 assert_eq!(parsed.max_response_tokens, Some(8192));
1873 assert!(!parsed.auto_compaction);
1874 assert!(!parsed.extensions_enabled);
1875 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1876 }
1877
1878 #[test]
1881 fn test_json_roundtrip() {
1882 let mut settings = Settings::default();
1883 settings.last_used_model = Some("gpt-4o".to_string());
1884 settings.last_used_provider = Some("openai".to_string());
1885 settings.theme = "dracula".to_string();
1886 settings.tool_timeout_seconds = 60;
1887 settings.default_temperature = Some(0.8);
1888 settings.max_response_tokens = Some(8192);
1889
1890 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1891 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1892
1893 assert_eq!(parsed.last_used_model, settings.last_used_model);
1894 assert_eq!(parsed.theme, settings.theme);
1895 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1896 assert_eq!(parsed.default_temperature, settings.default_temperature);
1897 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1898 }
1899
1900 #[test]
1901 fn test_json_serialize_for_format() {
1902 let mut settings = Settings::default();
1903 settings.last_used_model = Some("claude-3".to_string());
1904 settings.last_used_provider = Some("anthropic".to_string());
1905 settings.thinking_level = ThinkingLevel::Minimal;
1906
1907 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1908 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1909
1910 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1911 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1912 }
1913
1914 #[test]
1915 fn test_toml_serialize_for_format() {
1916 let mut settings = Settings::default();
1917 settings.last_used_model = Some("gemini-pro".to_string());
1918 settings.last_used_provider = Some("google".to_string());
1919 settings.thinking_level = ThinkingLevel::High;
1920
1921 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1922 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1923
1924 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1925 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1926 }
1927
1928 #[test]
1929 fn test_parse_from_str_json() {
1930 let json_content = r#"{
1931 "last_used_model": "gpt-4",
1932 "last_used_provider": "openai",
1933 "theme": "nord",
1934 "tool_timeout_seconds": 90
1935 }"#;
1936
1937 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1938 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1939 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1940 assert_eq!(settings.theme, "nord");
1941 assert_eq!(settings.tool_timeout_seconds, 90);
1942 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1944 assert!(settings.extensions_enabled);
1945 }
1946
1947 #[test]
1948 fn test_parse_from_str_toml() {
1949 let toml_content = r#"
1950last_used_model = "claude-opus"
1951last_used_provider = "anthropic"
1952theme = "monokai"
1953tool_timeout_seconds = 45
1954"#;
1955
1956 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1957 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1958 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1959 assert_eq!(settings.theme, "monokai");
1960 assert_eq!(settings.tool_timeout_seconds, 45);
1961 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1962 }
1963
1964 #[test]
1965 fn test_layer_file_json() {
1966 let base = Settings::default();
1967
1968 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1969 let json_content = r#"{
1970 "last_used_model": "gpt-4o",
1971 "last_used_provider": "openai",
1972 "theme": "dracula",
1973 "auto_compaction": false
1974 }"#;
1975 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1976
1977 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1978 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1979 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1980 assert_eq!(merged.theme, "dracula");
1981 assert!(!merged.auto_compaction);
1982 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1984 assert!(merged.extensions_enabled);
1985 assert_eq!(merged.tool_timeout_seconds, 120);
1986 }
1987
1988 #[test]
1989 fn test_layer_file_json_preserves_unset() {
1990 let mut base = Settings::default();
1991 base.last_used_provider = Some("deepseek".to_string());
1992
1993 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1994 let json_content = r#"{ "theme": "nord" }"#;
1995 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1996
1997 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1998 assert_eq!(merged.theme, "nord");
1999 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
2000 }
2001
2002 #[test]
2003 fn test_save_to_json() {
2004 let tmp = tempfile::tempdir().unwrap();
2005 let settings_path = tmp.path().join("settings.json");
2006
2007 let mut settings = Settings::default();
2008 settings.last_used_model = Some("gpt-4o".to_string());
2009 settings.last_used_provider = Some("openai".to_string());
2010 settings.theme = "dracula".to_string();
2011 settings.tool_timeout_seconds = 60;
2012
2013 settings.save_to(&settings_path).unwrap();
2014
2015 let content = fs::read_to_string(&settings_path).unwrap();
2017 let parsed: Settings = serde_json::from_str(&content).unwrap();
2018 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
2019 assert_eq!(parsed.theme, "dracula");
2020 assert_eq!(parsed.tool_timeout_seconds, 60);
2021 }
2022
2023 #[test]
2024 fn test_save_to_toml() {
2025 let tmp = tempfile::tempdir().unwrap();
2026 let settings_path = tmp.path().join("settings.toml");
2027
2028 let mut settings = Settings::default();
2029 settings.last_used_model = Some("gemini-pro".to_string());
2030 settings.last_used_provider = Some("google".to_string());
2031 settings.theme = "monokai".to_string();
2032 settings.tool_timeout_seconds = 90;
2033
2034 settings.save_to(&settings_path).unwrap();
2035
2036 let content = fs::read_to_string(&settings_path).unwrap();
2038 let parsed: Settings = toml::from_str(&content).unwrap();
2039 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2040 assert_eq!(parsed.theme, "monokai");
2041 assert_eq!(parsed.tool_timeout_seconds, 90);
2042 }
2043
2044 #[test]
2045 fn test_load_from_dir_with_json_project_config() {
2046 let _guard = EnvGuard::new(&[
2047 "OXI_MODEL",
2048 "OXI_PROVIDER",
2049 "OXI_THEME",
2050 "OXI_TOOL_TIMEOUT",
2051 "OXI_TEMPERATURE",
2052 "OXI_MAX_TOKENS",
2053 "OXI_SESSION_DIR",
2054 "OXI_STREAM",
2055 "OXI_EXTENSIONS_ENABLED",
2056 ]);
2057 let tmp = tempfile::tempdir().unwrap();
2058 let oxi_dir = tmp.path().join(".oxi");
2059 fs::create_dir_all(&oxi_dir).unwrap();
2060 let settings_path = oxi_dir.join("settings.json");
2061 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
2063 fs::write(&settings_path, json_content).unwrap();
2064
2065 let settings = Settings::load_from(tmp.path()).unwrap();
2066 assert_eq!(
2068 settings.last_used_model,
2069 Some("gemini-2.0-flash".to_string())
2070 );
2071 assert_eq!(settings.last_used_provider, Some("google".to_string()));
2072 }
2073
2074 #[test]
2075 fn test_find_project_settings_json_priority() {
2076 let tmp = tempfile::tempdir().unwrap();
2077 let oxi_dir = tmp.path().join(".oxi");
2078 fs::create_dir_all(&oxi_dir).unwrap();
2079
2080 let json_path = oxi_dir.join("settings.json");
2082 let toml_path = oxi_dir.join("settings.toml");
2083 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2084 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2085
2086 let found = Settings::find_project_settings(tmp.path());
2088 assert!(found.is_some());
2089 assert_eq!(
2090 found.unwrap().file_name().unwrap().to_str().unwrap(),
2091 "settings.json"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_find_project_settings_json_only() {
2097 let tmp = tempfile::tempdir().unwrap();
2098 let oxi_dir = tmp.path().join(".oxi");
2099 fs::create_dir_all(&oxi_dir).unwrap();
2100
2101 let json_path = oxi_dir.join("settings.json");
2102 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2103
2104 let found = Settings::find_project_settings(tmp.path());
2105 assert!(found.is_some());
2106 assert_eq!(
2107 found.unwrap().file_name().unwrap().to_str().unwrap(),
2108 "settings.json"
2109 );
2110 }
2111
2112 #[test]
2113 fn test_find_project_settings_toml_fallback() {
2114 let tmp = tempfile::tempdir().unwrap();
2115 let oxi_dir = tmp.path().join(".oxi");
2116 fs::create_dir_all(&oxi_dir).unwrap();
2117
2118 let toml_path = oxi_dir.join("settings.toml");
2119 fs::write(&toml_path, r#"theme = "test""#).unwrap();
2120
2121 let found = Settings::find_project_settings(tmp.path());
2122 assert!(found.is_some());
2123 assert_eq!(
2124 found.unwrap().file_name().unwrap().to_str().unwrap(),
2125 "settings.toml"
2126 );
2127 }
2128
2129 #[test]
2130 fn test_detect_format() {
2131 let json_path = PathBuf::from("/test/settings.json");
2132 let toml_path = PathBuf::from("/test/settings.toml");
2133 let unknown_path = PathBuf::from("/test/settings");
2134
2135 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2136 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2137 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2138 }
2140
2141 #[test]
2142 fn test_settings_format_extension() {
2143 assert_eq!(SettingsFormat::Json.extension(), "json");
2144 assert_eq!(SettingsFormat::Toml.extension(), "toml");
2145 }
2146
2147 #[test]
2148 fn test_layer_json_over_toml() {
2149 let tmp = tempfile::tempdir().unwrap();
2151 let oxi_dir = tmp.path().join(".oxi");
2152 fs::create_dir_all(&oxi_dir).unwrap();
2153
2154 let json_path = oxi_dir.join("settings.json");
2155 let toml_path = oxi_dir.join("settings.toml");
2156
2157 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2159 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2161
2162 let settings = Settings::load_from(tmp.path()).unwrap();
2164 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2165 }
2166
2167 #[test]
2168 fn test_mixed_format_loading() {
2169 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2171 let toml_content = r#"
2172last_used_model = "loaded-via-toml"
2173theme = "loaded-theme"
2174stream_responses = false
2175"#;
2176 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2177
2178 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2179 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2180 assert_eq!(merged.theme, "loaded-theme");
2181 assert!(!merged.stream_responses);
2182 }
2183
2184 #[test]
2185 fn test_merge_json_values() {
2186 let base = serde_json::json!({
2187 "version": 1,
2188 "theme": "default",
2189 "extensions": ["ext1"],
2190 "nested": {
2191 "a": 1,
2192 "b": 2
2193 }
2194 });
2195
2196 let override_ = serde_json::json!({
2197 "version": 2,
2198 "theme": "dark",
2199 "extensions": ["ext2"],
2200 "nested": {
2201 "b": 20,
2202 "c": 30
2203 }
2204 });
2205
2206 let merged = merge_json_values(base, override_);
2207
2208 assert_eq!(merged["version"], 2);
2209 assert_eq!(merged["theme"], "dark");
2210 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2212 assert_eq!(merged["nested"]["a"], 1);
2214 assert_eq!(merged["nested"]["b"], 20);
2215 assert_eq!(merged["nested"]["c"], 30);
2216 }
2217
2218 #[test]
2219 fn test_save_project_preserves_existing_format() {
2220 let tmp = tempfile::tempdir().unwrap();
2221 let oxi_dir = tmp.path().join(".oxi");
2222 fs::create_dir_all(&oxi_dir).unwrap();
2223
2224 let toml_path = oxi_dir.join("settings.toml");
2226 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2227
2228 let mut settings = Settings::default();
2229 settings.theme = "new-theme".to_string();
2230 settings.save_project(tmp.path()).unwrap();
2231
2232 let content = fs::read_to_string(&toml_path).unwrap();
2234 assert!(content.contains("new-theme"));
2235 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2236 }
2237
2238 #[test]
2239 fn test_save_project_creates_json_by_default() {
2240 let tmp = tempfile::tempdir().unwrap();
2241 let oxi_dir = tmp.path().join(".oxi");
2242 fs::create_dir_all(&oxi_dir).unwrap();
2243 let mut settings = Settings::default();
2246 settings.theme = "json-theme".to_string();
2247 settings.save_project(tmp.path()).unwrap();
2248
2249 let json_path = oxi_dir.join("settings.json");
2251 assert!(json_path.exists());
2252 let content = fs::read_to_string(&json_path).unwrap();
2253 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2254 assert!(content.contains("json-theme"));
2255 }
2256
2257 #[test]
2260 fn test_custom_provider_default_api() {
2261 use super::CustomProvider;
2262 let cp = CustomProvider {
2263 name: "test".to_string(),
2264 base_url: "https://api.test.com/v1".to_string(),
2265 api_key_env: "TEST_API_KEY".to_string(),
2266 api: super::default_custom_provider_api(),
2267 };
2268 assert_eq!(cp.api, "openai-completions");
2269 }
2270
2271 #[test]
2272 fn test_custom_provider_toml_deserialize() {
2273 let toml_content = r#"
2274[[custom_providers]]
2275name = "minimax"
2276base_url = "https://api.minimax.chat/v1"
2277api_key_env = "MINIMAX_API_KEY"
2278api = "openai-completions"
2279
2280[[custom_providers]]
2281name = "zai"
2282base_url = "https://api.z.ai/v1"
2283api_key_env = "ZAI_API_KEY"
2284api = "openai-responses"
2285"#;
2286 let settings: Settings = toml::from_str(toml_content).unwrap();
2287 assert_eq!(settings.custom_providers.len(), 2);
2288 assert_eq!(settings.custom_providers[0].name, "minimax");
2289 assert_eq!(
2290 settings.custom_providers[0].base_url,
2291 "https://api.minimax.chat/v1"
2292 );
2293 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2294 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2295 assert_eq!(settings.custom_providers[1].name, "zai");
2296 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2297 }
2298
2299 #[test]
2300 fn test_custom_provider_json_deserialize() {
2301 let json_content = r#"{
2302 "custom_providers": [
2303 {
2304 "name": "minimax",
2305 "base_url": "https://api.minimax.chat/v1",
2306 "api_key_env": "MINIMAX_API_KEY",
2307 "api": "openai-completions"
2308 }
2309 ]
2310 }"#;
2311 let settings: Settings = serde_json::from_str(json_content).unwrap();
2312 assert_eq!(settings.custom_providers.len(), 1);
2313 assert_eq!(settings.custom_providers[0].name, "minimax");
2314 }
2315
2316 #[test]
2317 fn test_custom_provider_toml_roundtrip() {
2318 let mut settings = Settings::default();
2319 settings.custom_providers.push(super::CustomProvider {
2320 name: "test".to_string(),
2321 base_url: "https://api.test.com/v1".to_string(),
2322 api_key_env: "TEST_API_KEY".to_string(),
2323 api: "openai-completions".to_string(),
2324 });
2325
2326 let toml_str = toml::to_string_pretty(&settings).unwrap();
2327 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2328 assert_eq!(parsed.custom_providers.len(), 1);
2329 assert_eq!(parsed.custom_providers[0].name, "test");
2330 assert_eq!(
2331 parsed.custom_providers[0].base_url,
2332 "https://api.test.com/v1"
2333 );
2334 }
2335
2336 #[test]
2337 fn test_custom_provider_defaults_empty() {
2338 let settings = Settings::default();
2339 assert!(settings.custom_providers.is_empty());
2340 }
2341
2342 #[test]
2343 fn test_custom_provider_layer_file() {
2344 let base = Settings::default();
2345
2346 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2347 let toml_content = r#"
2348[[custom_providers]]
2349name = "my-provider"
2350base_url = "https://api.my-provider.com/v1"
2351api_key_env = "MY_PROVIDER_API_KEY"
2352"#;
2353 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2354
2355 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2356 assert_eq!(merged.custom_providers.len(), 1);
2357 assert_eq!(merged.custom_providers[0].name, "my-provider");
2358 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2360 }
2361}