1use anyhow::{Context, Result};
22use oxi_tui::GlyphSet;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::env;
26use std::fs;
27use std::path::{Path, PathBuf};
28
29const SETTINGS_VERSION: u32 = 8;
38
39pub const KNOWN_CHANNELS: &[(&str, &str)] = &[
50 ("response", "Your conversational responses to the user"),
51 (
52 "code_comment",
53 "Code comments you write (//, /* */, #, etc.)",
54 ),
55 (
56 "documentation",
57 "Documentation (markdown files, README, AGENTS.md, doc comments)",
58 ),
59 ("commit_message", "Git commit messages (subject + body)"),
60];
61
62pub const KNOWN_LANGS: &[(&str, &str)] = &[
73 ("auto", "Auto (match user)"),
74 ("en", "English"),
75 ("ko", "Korean (한국어)"),
76 ("ja", "Japanese (日本語)"),
77 ("zh", "Chinese (中文)"),
78 ("es", "Spanish"),
79 ("fr", "French"),
80 ("de", "German"),
81];
82
83#[allow(dead_code)]
86const ENV_PREFIX: &str = "OXI_";
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
90#[serde(rename_all = "snake_case")]
91pub enum ThinkingLevel {
92 #[default]
94 Off,
95 Minimal,
97 Low,
99 Medium,
101 High,
103 XHigh,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
113#[serde(rename_all = "snake_case")]
114pub enum EditFormat {
115 #[default]
117 Hashline,
118 StrReplace,
120}
121#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct CustomProvider {
127 pub name: String,
129 pub base_url: String,
131 pub api_key_env: String,
133 #[serde(default = "default_custom_provider_api")]
135 pub api: String,
136}
137
138fn default_custom_provider_api() -> String {
139 "openai-completions".to_string()
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Settings {
145 #[serde(default)]
148 pub version: u32,
149
150 #[serde(default = "default_thinking_level")]
153 pub thinking_level: ThinkingLevel,
154 #[serde(default = "default_theme")]
156 pub theme: String,
157
158 #[serde(default)]
165 pub glyph_set: GlyphSet,
166
167 #[serde(default, skip_serializing)]
169 pub default_model: Option<String>,
170
171 #[serde(default, skip_serializing)]
173 pub default_provider: Option<String>,
174
175 #[serde(default)]
178 pub last_used_model: Option<String>,
179
180 #[serde(default)]
182 pub last_used_provider: Option<String>,
183
184 pub max_tokens: Option<u32>,
186
187 pub temperature: Option<f32>,
189
190 pub default_temperature: Option<f64>,
192
193 pub max_response_tokens: Option<usize>,
195
196 #[serde(default = "default_session_history_size")]
199 pub session_history_size: usize,
200
201 pub session_dir: Option<PathBuf>,
203
204 #[serde(default = "default_true")]
207 pub stream_responses: bool,
208
209 #[serde(default = "default_true")]
211 pub extensions_enabled: bool,
212
213 #[serde(default = "default_true")]
215 pub auto_compaction: bool,
216
217 #[serde(default)]
220 pub disabled_tools: Vec<String>,
221
222 #[serde(default = "default_tool_timeout")]
225 pub tool_timeout_seconds: u64,
226
227 #[serde(default, alias = "questionnaire_timeout_secs")]
230 pub ask_timeout_secs: u64,
231
232 #[serde(default)]
235 pub extensions: Vec<String>,
236
237 #[serde(default)]
239 pub skills: Vec<String>,
240
241 #[serde(default)]
243 pub prompts: Vec<String>,
244
245 #[serde(default)]
247 pub themes: Vec<String>,
248
249 #[serde(default)]
252 pub custom_providers: Vec<CustomProvider>,
253
254 #[serde(default)]
259 pub dynamic_models: HashMap<String, Vec<String>>,
260
261 #[serde(default = "default_false")]
264 pub enable_routing: bool,
265
266 #[serde(default)]
268 pub router_profile: Option<String>,
269
270 #[serde(default = "default_true")]
272 pub prefer_cost_efficient: bool,
273
274 #[serde(default)]
276 pub fallback_chain: Vec<String>,
277
278 #[serde(default = "default_true")]
280 pub enable_fallback: bool,
281
282 #[serde(default)]
284 pub disable_fallback: bool,
285
286 #[serde(default = "default_circuit_failure_threshold")]
288 pub circuit_breaker_failure_threshold: u32,
289
290 #[serde(default = "default_circuit_open_duration_secs")]
292 pub circuit_breaker_open_duration_secs: u64,
293
294 #[serde(default)]
299 pub keybindings: HashMap<String, Vec<String>>,
300
301 #[serde(default)]
347 pub output_languages: HashMap<String, String>,
348
349 #[serde(default = "default_false")]
368 pub language_policy_enabled: bool,
369
370 #[serde(default)]
375 pub edit_format: EditFormat,
376
377 #[serde(default = "default_false")]
381 pub memory_enabled: bool,
382
383 #[serde(default)]
386 pub memory_db_path: Option<PathBuf>,
387
388 #[serde(default = "default_false")]
392 pub ttsr_enabled: bool,
393
394 #[serde(default = "default_ttsr_mode")]
396 pub ttsr_interrupt_mode: String,
397}
398
399fn default_theme() -> String {
400 "default".to_string()
401}
402
403fn default_thinking_level() -> ThinkingLevel {
404 ThinkingLevel::Medium
405}
406
407fn default_session_history_size() -> usize {
408 100
409}
410
411fn default_true() -> bool {
412 true
413}
414
415fn default_false() -> bool {
416 false
417}
418
419fn default_ttsr_mode() -> String {
420 "prose_only".to_string()
421}
422
423fn default_circuit_failure_threshold() -> u32 {
424 5
425}
426
427fn default_circuit_open_duration_secs() -> u64 {
428 30
429}
430
431fn default_tool_timeout() -> u64 {
432 120
433}
434
435impl Default for Settings {
436 fn default() -> Self {
437 Self {
438 version: SETTINGS_VERSION,
439 thinking_level: ThinkingLevel::Medium,
440 theme: default_theme(),
441 glyph_set: GlyphSet::default(),
442 last_used_model: None,
443 last_used_provider: None,
444 default_model: None,
445 default_provider: None,
446 max_tokens: None,
447 temperature: None,
448 default_temperature: None,
449 max_response_tokens: None,
450 session_history_size: default_session_history_size(),
451 session_dir: None,
452 stream_responses: true,
453 extensions_enabled: true,
454 auto_compaction: true,
455 disabled_tools: Vec::new(),
456 tool_timeout_seconds: default_tool_timeout(),
457 ask_timeout_secs: 0,
458 extensions: Vec::new(),
459 skills: Vec::new(),
460 prompts: Vec::new(),
461 themes: Vec::new(),
462 custom_providers: Vec::new(),
463 dynamic_models: HashMap::new(),
464 enable_routing: false,
466 router_profile: None,
467 prefer_cost_efficient: true,
468 fallback_chain: Vec::new(),
469 enable_fallback: true,
470 disable_fallback: false,
471 circuit_breaker_failure_threshold: 5,
472 circuit_breaker_open_duration_secs: 30,
473 keybindings: HashMap::new(),
474 output_languages: HashMap::new(),
475 language_policy_enabled: false,
476 edit_format: EditFormat::default(),
477 memory_enabled: false,
478 memory_db_path: None,
479 ttsr_enabled: false,
480 ttsr_interrupt_mode: default_ttsr_mode(),
481 }
482 }
483}
484
485impl Settings {
486 pub fn settings_dir() -> Result<PathBuf> {
490 let base = dirs::home_dir().context("Cannot determine home directory")?;
491 Ok(base.join(".oxi"))
492 }
493
494 pub fn settings_toml_path() -> Result<PathBuf> {
496 Ok(Self::settings_dir()?.join("settings.toml"))
497 }
498
499 pub fn settings_json_path() -> Result<PathBuf> {
501 Ok(Self::settings_dir()?.join("settings.json"))
502 }
503
504 pub fn settings_path() -> Result<PathBuf> {
511 let json_path = Self::settings_json_path()?;
512 let toml_path = Self::settings_toml_path()?;
513
514 if json_path.exists() && toml_path.exists() {
515 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
517 return Ok(json_path);
518 }
519
520 if json_path.exists() {
521 return Ok(json_path);
522 }
523
524 if toml_path.exists() {
525 return Ok(toml_path);
526 }
527
528 Ok(json_path)
530 }
531
532 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
537 let json_path = Self::settings_json_path()?;
538 let toml_path = Self::settings_toml_path()?;
539
540 let (primary, secondary) = if prefer_json {
541 (&json_path, &toml_path)
542 } else {
543 (&toml_path, &json_path)
544 };
545
546 if primary.exists() {
547 return Ok(primary.clone());
548 }
549
550 if secondary.exists() {
551 return Ok(secondary.clone());
552 }
553
554 Ok(primary.clone())
556 }
557
558 pub fn detect_format(path: &Path) -> SettingsFormat {
560 match path.extension().and_then(|e| e.to_str()) {
561 Some("json") => SettingsFormat::Json,
562 Some("toml") => SettingsFormat::Toml,
563 _ => SettingsFormat::Json, }
565 }
566
567 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
572 let mut dir = start_dir.to_path_buf();
573 loop {
574 let json_candidate = dir.join(".oxi").join("settings.json");
576 if json_candidate.exists() {
577 return Some(json_candidate);
578 }
579
580 let toml_candidate = dir.join(".oxi").join("settings.toml");
581 if toml_candidate.exists() {
582 return Some(toml_candidate);
583 }
584
585 if !dir.pop() {
586 return None;
587 }
588 }
589 }
590
591 pub fn effective_session_dir(&self) -> Result<PathBuf> {
595 if let Some(ref dir) = self.session_dir {
596 return Ok(dir.clone());
597 }
598 Ok(Self::settings_dir()?.join("sessions"))
599 }
600
601 pub fn load() -> Result<Self> {
619 Self::load_from_cwd()
620 }
621
622 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
628 Self::load_from_with(dir, None)
629 }
630
631 pub fn load_from_with(
650 dir: &std::path::Path,
651 global_override: Option<&std::path::Path>,
652 ) -> Result<Self> {
653 let mut settings = Settings::default();
655
656 let resolved_global: Option<std::path::PathBuf> = match global_override {
659 Some(p) => Some(p.to_path_buf()),
660 None => Self::settings_path().ok(),
661 };
662 if let Some(ref gp) = resolved_global
663 && gp.exists()
664 {
665 settings = Self::layer_file(&settings, gp)?;
666 }
667
668 if let Some(project_path) = Self::find_project_settings(dir) {
670 settings = Self::layer_file(&settings, &project_path)?;
671 }
672
673 settings.apply_env();
675
676 settings = Self::migrate(settings)?;
678
679 settings.validate_output_languages();
681
682 Ok(settings)
683 }
684
685 fn validate_output_languages(&mut self) {
692 if self.output_languages.is_empty() {
693 return;
694 }
695 let known_langs: std::collections::HashSet<&str> =
696 KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
697
698 for (channel, lang) in &self.output_languages {
699 if !known_langs.contains(lang.as_str()) {
700 tracing::warn!(
701 "Unknown output_languages language code '{}' for channel '{}'. \
702 Keeping as-is (the model will likely understand).",
703 lang,
704 channel
705 );
706 }
707 }
708 }
709
710 pub fn load_from_cwd() -> Result<Self> {
712 let cwd = env::current_dir().context("Cannot determine current directory")?;
713 Self::load_from(&cwd)
714 }
715
716 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
722 let content = fs::read_to_string(path)
723 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
724
725 let format = Self::detect_format(path);
726 let overlay: serde_json::Value = match format {
727 SettingsFormat::Toml => {
728 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
729 format!("Failed to parse TOML settings from {}", path.display())
730 })?;
731 toml_value_to_json(toml_value)
733 }
734 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
735 format!("Failed to parse JSON settings from {}", path.display())
736 })?,
737 };
738
739 let base_json =
743 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
744
745 let merged = merge_json_values(base_json, overlay);
746 let result: Settings =
747 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
748
749 Ok(result)
750 }
751
752 #[allow(dead_code)]
778 pub fn apply_env(&mut self) {
779 }
783
784 #[allow(dead_code)]
790 pub fn from_env() -> Self {
791 Self::default()
792 }
793
794 pub fn save(&self) -> Result<()> {
801 let dir = Self::settings_dir()?;
802 let path = Self::settings_path()?;
803
804 if !dir.exists() {
805 fs::create_dir_all(&dir).with_context(|| {
806 format!("Failed to create settings directory {}", dir.display())
807 })?;
808 }
809
810 let format = Self::detect_format(&path);
811 let content = Self::serialize_for_format(self, format)?;
812
813 let tmp_path = path.with_extension("tmp");
815 fs::write(&tmp_path, &content)
816 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
817 fs::rename(&tmp_path, &path)
818 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
819
820 Ok(())
821 }
822
823 pub fn save_to(&self, path: &Path) -> Result<()> {
825 if let Some(parent) = path.parent()
826 && !parent.exists()
827 {
828 fs::create_dir_all(parent)
829 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
830 }
831
832 let format = Self::detect_format(path);
833 let content = Self::serialize_for_format(self, format)?;
834
835 let tmp_path = path.with_extension("tmp");
837 fs::write(&tmp_path, &content)
838 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
839 fs::rename(&tmp_path, path)
840 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
841
842 Ok(())
843 }
844
845 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
849 let dir = project_dir.join(".oxi");
850
851 if !dir.exists() {
852 fs::create_dir_all(&dir).with_context(|| {
853 format!(
854 "Failed to create project settings directory {}",
855 dir.display()
856 )
857 })?;
858 }
859
860 let json_path = dir.join("settings.json");
862 let toml_path = dir.join("settings.toml");
863
864 let path = if json_path.exists() {
865 &json_path
866 } else if toml_path.exists() {
867 &toml_path
868 } else {
869 &json_path
871 };
872
873 let format = Self::detect_format(path);
874 let content = Self::serialize_for_format(self, format)?;
875
876 let tmp_path = path.with_extension("tmp");
878 fs::write(&tmp_path, &content)
879 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
880 fs::rename(&tmp_path, path)
881 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
882
883 Ok(())
884 }
885
886 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
888 match format {
889 SettingsFormat::Toml => {
890 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
891 }
892 SettingsFormat::Json => serde_json::to_string_pretty(settings)
893 .context("Failed to serialize settings to JSON"),
894 }
895 }
896
897 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
899 match format {
900 SettingsFormat::Toml => {
901 toml::from_str(content).context("Failed to parse TOML settings")
902 }
903 SettingsFormat::Json => {
904 serde_json::from_str(content).context("Failed to parse JSON settings")
905 }
906 }
907 }
908
909 pub fn merge_cli(
922 &mut self,
923 model: Option<String>,
924 provider: Option<String>,
925 enable_routing: Option<bool>,
926 prefer_cost_efficient: Option<bool>,
927 fallback_chain: Option<Vec<String>>,
928 disable_fallback: Option<bool>,
929 ) {
930 if let Some(m) = model {
931 self.last_used_model = Some(m);
932 }
933 if let Some(p) = provider {
934 self.last_used_provider = Some(p);
935 }
936 if let Some(r) = enable_routing {
937 self.enable_routing = r;
938 }
939 if let Some(p) = prefer_cost_efficient {
940 self.prefer_cost_efficient = p;
941 }
942 if let Some(fc) = fallback_chain
943 && !fc.is_empty()
944 {
945 self.fallback_chain = fc;
946 }
947 if let Some(df) = disable_fallback {
948 self.disable_fallback = df;
949 if df {
951 self.enable_fallback = false;
952 }
953 }
954 }
955
956 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
959 cli_model.map(String::from).or_else(|| {
960 let model = self.last_used_model.as_ref()?;
965 if model.contains('/') {
966 Some(model.clone())
968 } else if let Some(ref provider) = self.last_used_provider {
969 Some(format!("{}/{}", provider, model))
971 } else {
972 Some(model.clone())
973 }
974 })
975 }
976
977 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
980 cli_provider
981 .map(String::from)
982 .or_else(|| self.last_used_provider.clone())
983 }
984
985 pub fn effective_temperature(&self) -> Option<f64> {
988 self.default_temperature
989 .or(self.temperature.map(|t| t as f64))
990 }
991
992 pub fn effective_max_tokens(&self) -> Option<usize> {
995 self.max_response_tokens
996 .or(self.max_tokens.map(|t| t as usize))
997 }
998
999 pub fn router_profile(&self) -> Option<&str> {
1001 self.router_profile.as_deref()
1002 }
1003
1004 pub fn save_last_used(model_id: &str) {
1010 if let Ok(mut settings) = Self::load() {
1011 if let Some((provider, model)) = model_id.split_once('/') {
1012 settings.last_used_provider = Some(provider.to_string());
1013 settings.last_used_model = Some(model.to_string());
1014 } else {
1015 settings.last_used_model = Some(model_id.to_string());
1016 }
1017 let _ = settings.save();
1018 }
1019 }
1020
1021 pub fn save_theme(&mut self, name: &str) -> Result<()> {
1023 self.theme = name.to_string();
1024 self.save()
1025 }
1026
1027 pub fn get_theme_name(&self) -> String {
1029 if self.theme.is_empty() || self.theme == "default" {
1030 "oxi_dark".to_string()
1031 } else {
1032 self.theme.clone()
1033 }
1034 }
1035
1036 fn migrate(settings: Settings) -> Result<Settings> {
1050 let mut settings = settings;
1051
1052 match settings.version {
1053 SETTINGS_VERSION => {
1054 }
1056 0 => {
1057 if settings.tool_timeout_seconds == 0 {
1060 settings.tool_timeout_seconds = default_tool_timeout();
1061 }
1062 settings.version = SETTINGS_VERSION;
1063
1064 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1065 }
1066 1 | 2 => {
1067 settings.version = SETTINGS_VERSION;
1072 tracing::info!(
1073 "Migrated settings from version {} to {} (dynamic_models + output_languages + language_policy_enabled defaults applied)",
1074 settings.version,
1075 SETTINGS_VERSION
1076 );
1077 }
1078 3 => {
1079 if let Some(model) = settings.default_model.take() {
1081 if let Some((provider, model_name)) = model.split_once('/') {
1082 settings.last_used_provider = Some(provider.to_string());
1083 settings.last_used_model = Some(model_name.to_string());
1084 } else {
1085 settings.last_used_model = Some(model);
1086 }
1087 }
1088 settings.version = SETTINGS_VERSION;
1090 tracing::info!(
1091 "Migrated settings from version 3 to {} (default_model → last_used_model; output_languages + language_policy_enabled defaults)",
1092 SETTINGS_VERSION
1093 );
1094 }
1095 4 => {
1096 settings.version = SETTINGS_VERSION;
1100 tracing::info!(
1101 "Migrated settings from version 4 to {} (added output_languages + language_policy_enabled, both defaulted to off)",
1102 SETTINGS_VERSION
1103 );
1104 }
1105 5 => {
1106 settings.version = SETTINGS_VERSION;
1112 tracing::info!(
1113 "Migrated settings from version 5 to {} (added language_policy_enabled, defaulting to OFF — toggle ON in /settings to activate existing channels)",
1114 SETTINGS_VERSION
1115 );
1116 }
1117 6 => {
1118 settings.version = SETTINGS_VERSION;
1121 tracing::info!(
1122 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1123 SETTINGS_VERSION
1124 );
1125 }
1126 7 => {
1127 settings.version = SETTINGS_VERSION;
1130 tracing::info!(
1131 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1132 SETTINGS_VERSION
1133 );
1134 }
1135 v if v > SETTINGS_VERSION => {
1136 anyhow::bail!(
1138 "Settings version {} is newer than supported version {}. \
1139 Please update oxi.",
1140 v,
1141 SETTINGS_VERSION
1142 );
1143 }
1144 v => {
1145 tracing::warn!(
1147 "Unknown settings version {}, attempting migration to {}",
1148 v,
1149 SETTINGS_VERSION
1150 );
1151 settings.version = SETTINGS_VERSION;
1152 }
1153 }
1154
1155 Ok(settings)
1156 }
1157}
1158
1159#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1163pub enum SettingsFormat {
1164 #[default]
1166 Json,
1167 Toml,
1169}
1170
1171impl SettingsFormat {
1172 pub fn extension(&self) -> &'static str {
1174 match self {
1175 SettingsFormat::Json => "json",
1176 SettingsFormat::Toml => "toml",
1177 }
1178 }
1179}
1180
1181fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1185 match toml {
1186 toml::Value::String(s) => serde_json::Value::String(s),
1187 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1188 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1189 .map(serde_json::Value::Number)
1190 .unwrap_or(serde_json::Value::Null),
1191 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1192 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1193 toml::Value::Array(arr) => {
1194 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1195 }
1196 toml::Value::Table(table) => {
1197 let obj = table
1198 .into_iter()
1199 .map(|(k, v)| (k, toml_value_to_json(v)))
1200 .collect();
1201 serde_json::Value::Object(obj)
1202 }
1203 }
1204}
1205
1206fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1208 match (base, override_) {
1209 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1211 let mut result = base_map;
1212 for (key, override_value) in override_map {
1213 let base_value = result.remove(&key);
1214 let merged = match base_value {
1215 Some(base_v) => merge_json_values(base_v, override_value),
1216 None => override_value,
1217 };
1218 result.insert(key, merged);
1219 }
1220 serde_json::Value::Object(result)
1221 }
1222 (_, override_) => override_,
1224 }
1225}
1226
1227pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1229 match s.to_lowercase().as_str() {
1230 "off" | "none" => Some(ThinkingLevel::Off),
1231 "minimal" => Some(ThinkingLevel::Minimal),
1232 "low" => Some(ThinkingLevel::Low),
1233 "medium" | "standard" => Some(ThinkingLevel::Medium),
1234 "high" | "thorough" => Some(ThinkingLevel::High),
1235 "xhigh" => Some(ThinkingLevel::XHigh),
1236 _ => None,
1237 }
1238}
1239
1240#[allow(dead_code)]
1242fn parse_boolish(s: &str) -> Result<bool> {
1243 match s.to_lowercase().as_str() {
1244 "true" | "1" | "yes" | "on" => Ok(true),
1245 "false" | "0" | "no" | "off" => Ok(false),
1246 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1247 }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253 use std::io::Write as IoWrite;
1254 use std::sync::Mutex;
1255
1256 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1259
1260 struct EnvGuard {
1263 saved: Vec<(String, Option<String>)>,
1264 }
1265
1266 impl EnvGuard {
1267 fn new(vars: &[&str]) -> Self {
1268 let saved = vars
1269 .iter()
1270 .map(|&name| {
1271 let old = env::var(name).ok();
1272 unsafe { env::remove_var(name) };
1274 (name.to_string(), old)
1275 })
1276 .collect();
1277 Self { saved }
1278 }
1279 }
1280
1281 impl Drop for EnvGuard {
1282 fn drop(&mut self) {
1283 for (name, old) in self.saved.drain(..) {
1284 match old {
1285 Some(val) => unsafe { env::set_var(&name, val) },
1287 None => unsafe { env::remove_var(&name) },
1288 }
1289 }
1290 }
1291 }
1292
1293 #[test]
1296 fn test_default_settings() {
1297 let settings = Settings::default();
1298 assert_eq!(settings.version, SETTINGS_VERSION);
1299 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1300 assert_eq!(settings.theme, "default");
1301 assert!(settings.last_used_model.is_none());
1302 assert!(settings.last_used_provider.is_none());
1303 assert!(settings.extensions_enabled);
1304 assert!(settings.auto_compaction);
1305 assert_eq!(settings.tool_timeout_seconds, 120);
1306 assert!(settings.stream_responses);
1307 }
1308
1309 #[test]
1310 fn test_merge_cli() {
1311 let mut settings = Settings::default();
1312 settings.last_used_model = Some("gpt-4o".to_string());
1313
1314 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1315 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1316
1317 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1318 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1319
1320 settings.merge_cli(
1322 None,
1323 None,
1324 Some(true),
1325 Some(false),
1326 Some(vec!["openai/gpt-4o".to_string()]),
1327 Some(false),
1328 );
1329 assert!(settings.enable_routing);
1330 assert!(!settings.prefer_cost_efficient);
1331 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1332 assert!(!settings.disable_fallback);
1333
1334 let mut settings2 = Settings::default();
1336 settings2.merge_cli(None, None, None, None, None, Some(true));
1337 assert!(settings2.disable_fallback);
1338 assert!(!settings2.enable_fallback);
1339 }
1340
1341 #[test]
1344 fn test_layer_file_overrides() {
1345 let base = Settings::default();
1346
1347 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1348 let toml_content = r#"
1349last_used_model = "openai/gpt-4o"
1350theme = "dracula"
1351"#;
1352 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1353
1354 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1355 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1356 assert_eq!(merged.theme, "dracula");
1357 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1359 assert!(merged.extensions_enabled);
1360 }
1361
1362 #[test]
1363 fn test_layer_file_preserves_unset() {
1364 let mut base = Settings::default();
1365 base.last_used_provider = Some("deepseek".to_string());
1366
1367 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1368 let toml_content = "theme = \"monokai\"\n";
1370 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1371
1372 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1373 assert_eq!(merged.theme, "monokai");
1374 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1375 }
1376
1377 #[test]
1378 fn test_load_from_dir_with_project_config() {
1379 let _guard = EnvGuard::new(&[
1380 "OXI_MODEL",
1381 "OXI_PROVIDER",
1382 "OXI_THEME",
1383 "OXI_TOOL_TIMEOUT",
1384 "OXI_TEMPERATURE",
1385 "OXI_MAX_TOKENS",
1386 "OXI_SESSION_DIR",
1387 "OXI_STREAM",
1388 "OXI_EXTENSIONS_ENABLED",
1389 ]);
1390 let tmp = tempfile::tempdir().unwrap();
1391 let oxi_dir = tmp.path().join(".oxi");
1392 fs::create_dir_all(&oxi_dir).unwrap();
1393 let settings_path = oxi_dir.join("settings.toml");
1394 fs::write(
1396 &settings_path,
1397 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1398 )
1399 .unwrap();
1400
1401 let settings = Settings::load_from(tmp.path()).unwrap();
1402 assert_eq!(
1404 settings.last_used_model,
1405 Some("gemini-2.0-flash".to_string())
1406 );
1407 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1408 }
1409
1410 #[test]
1411 fn test_load_from_dir_no_config() {
1412 let _guard = EnvGuard::new(&[
1414 "OXI_MODEL",
1415 "OXI_PROVIDER",
1416 "OXI_THEME",
1417 "OXI_TOOL_TIMEOUT",
1418 "OXI_TEMPERATURE",
1419 "OXI_MAX_TOKENS",
1420 "OXI_SESSION_DIR",
1421 "OXI_STREAM",
1422 "OXI_EXTENSIONS_ENABLED",
1423 ]);
1424 let tmp = tempfile::tempdir().unwrap();
1425 let global = tmp.path().join("nonexistent-settings.json");
1430 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1431 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1432 }
1433 #[test]
1434 fn test_from_env() {
1435 let _guard = EnvGuard::new(&[
1438 "OXI_MODEL",
1440 "OXI_THEME",
1441 "OXI_TOOL_TIMEOUT",
1442 "OXI_PROVIDER",
1443 "OXI_DEFAULT_MODEL",
1444 ]);
1445
1446 let settings = Settings::from_env();
1447 assert_eq!(settings.last_used_model, None);
1449 assert_eq!(settings.theme, "default");
1450 assert_eq!(settings.tool_timeout_seconds, 120);
1451 }
1452
1453 #[test]
1454 fn test_apply_env_boolish() {
1455 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1458 unsafe { env::set_var("OXI_STREAM", "false") };
1459 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1460
1461 let mut settings = Settings::default();
1462 settings.apply_env();
1463 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1467
1468 #[test]
1469 fn test_apply_env_temperature() {
1470 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1472 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1473
1474 let mut settings = Settings::default();
1475 settings.apply_env();
1476 assert_eq!(settings.default_temperature, None);
1478 }
1479
1480 #[test]
1481 fn test_env_does_not_override_when_unset() {
1482 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1483 let settings = Settings::from_env();
1484 assert!(settings.last_used_model.is_none());
1485 assert!(settings.last_used_provider.is_none());
1486 }
1487
1488 #[test]
1489 fn test_parse_thinking_level() {
1490 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1491 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1492 assert_eq!(
1493 parse_thinking_level("MINIMAL"),
1494 Some(ThinkingLevel::Minimal)
1495 );
1496 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1497 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1498 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1499 assert_eq!(
1500 parse_thinking_level("Standard"),
1501 Some(ThinkingLevel::Medium)
1502 );
1503 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1504 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1505 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1506 assert_eq!(parse_thinking_level("invalid"), None);
1507 }
1508
1509 #[test]
1510 fn test_parse_boolish() {
1511 assert!(parse_boolish("true").unwrap());
1512 assert!(parse_boolish("1").unwrap());
1513 assert!(parse_boolish("yes").unwrap());
1514 assert!(parse_boolish("ON").unwrap());
1515 assert!(!parse_boolish("false").unwrap());
1516 assert!(!parse_boolish("0").unwrap());
1517 assert!(!parse_boolish("no").unwrap());
1518 assert!(!parse_boolish("OFF").unwrap());
1519 assert!(parse_boolish("maybe").is_err());
1520 }
1521
1522 #[test]
1525 fn test_effective_model_returns_last_used() {
1526 let mut settings = Settings::default();
1527 settings.last_used_model = Some("openai/gpt-4o".to_string());
1528 assert_eq!(
1529 settings.effective_model(None),
1530 Some("openai/gpt-4o".to_string())
1531 );
1532 }
1533
1534 #[test]
1535 fn test_effective_model_cli_overrides() {
1536 let mut settings = Settings::default();
1537 settings.last_used_model = Some("openai/gpt-4o".to_string());
1538 assert_eq!(
1539 settings.effective_model(Some("anthropic/claude-3")),
1540 Some("anthropic/claude-3".to_string())
1541 );
1542 }
1543
1544 #[test]
1545 fn test_effective_model_none_when_unset() {
1546 let settings = Settings::default();
1547 assert_eq!(settings.effective_model(None), None);
1548 }
1549
1550 #[test]
1551 fn test_effective_model_falls_back_to_last_used() {
1552 let mut settings = Settings::default();
1553 settings.last_used_model = Some("anthropic/claude-3".to_string());
1554 assert_eq!(
1555 settings.effective_model(None),
1556 Some("anthropic/claude-3".to_string())
1557 );
1558 }
1559
1560 #[test]
1561 fn test_effective_model_returns_none_when_nothing_set() {
1562 let settings = Settings::default();
1563 assert_eq!(settings.effective_model(None), None);
1564 }
1565
1566 #[test]
1567 fn test_effective_temperature_prefers_f64() {
1568 let mut settings = Settings::default();
1569 settings.temperature = Some(0.5);
1570 settings.default_temperature = Some(0.7);
1571 assert_eq!(settings.effective_temperature(), Some(0.7));
1572 }
1573
1574 #[test]
1575 fn test_effective_temperature_falls_back_to_f32() {
1576 let mut settings = Settings::default();
1577 settings.temperature = Some(0.5);
1578 assert_eq!(settings.effective_temperature(), Some(0.5));
1579 }
1580
1581 #[test]
1582 fn test_effective_max_tokens_prefers_usize() {
1583 let mut settings = Settings::default();
1584 settings.max_tokens = Some(1024);
1585 settings.max_response_tokens = Some(4096);
1586 assert_eq!(settings.effective_max_tokens(), Some(4096));
1587 }
1588
1589 #[test]
1590 fn test_effective_max_tokens_falls_back_to_u32() {
1591 let mut settings = Settings::default();
1592 settings.max_tokens = Some(1024);
1593 assert_eq!(settings.effective_max_tokens(), Some(1024));
1594 }
1595
1596 #[test]
1599 fn test_effective_session_dir_default() {
1600 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1601 let settings = Settings::default();
1602 let dir = settings.effective_session_dir().unwrap();
1603 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1604 }
1605
1606 #[test]
1607 fn test_effective_session_dir_from_field() {
1608 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1609 let mut settings = Settings::default();
1610 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1611 assert_eq!(
1612 settings.effective_session_dir().unwrap(),
1613 PathBuf::from("/tmp/oxi-sessions")
1614 );
1615 }
1616
1617 #[test]
1618 fn test_effective_session_dir_env_disabled() {
1619 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1622 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1623 let settings = Settings::default();
1624 let dir = settings.effective_session_dir().unwrap();
1626 assert!(
1627 dir.ends_with("sessions"),
1628 "expected default sessions dir, got: {:?}",
1629 dir
1630 );
1631 }
1632
1633 #[test]
1636 fn test_migration_v0_to_v1() {
1637 let mut settings = Settings::default();
1638 settings.version = 0;
1639 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1642 assert_eq!(migrated.version, SETTINGS_VERSION);
1643 assert_eq!(migrated.tool_timeout_seconds, 120);
1644 }
1645
1646 #[test]
1647 fn test_migration_already_current() {
1648 let settings = Settings::default();
1649 let migrated = Settings::migrate(settings).unwrap();
1650 assert_eq!(migrated.version, SETTINGS_VERSION);
1651 }
1652
1653 #[test]
1654 fn test_migration_v3_to_v4_splits_model() {
1655 let mut settings = Settings::default();
1656 settings.version = 3;
1657 settings.default_model = Some("openai/gpt-4o".to_string());
1658 settings.default_provider = None;
1659
1660 let migrated = Settings::migrate(settings).unwrap();
1661 assert_eq!(migrated.version, SETTINGS_VERSION);
1662 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1663 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1664 }
1665
1666 #[test]
1667 fn test_migration_v3_no_slash_keeps_model() {
1668 let mut settings = Settings::default();
1669 settings.version = 3;
1670 settings.default_model = Some("bare-model-name".to_string());
1671
1672 let migrated = Settings::migrate(settings).unwrap();
1673 assert_eq!(migrated.version, SETTINGS_VERSION);
1674 assert_eq!(
1675 migrated.last_used_model,
1676 Some("bare-model-name".to_string())
1677 );
1678 }
1679
1680 #[test]
1681 fn test_migration_future_version_fails() {
1682 let mut settings = Settings::default();
1683 settings.version = 9999;
1684 assert!(Settings::migrate(settings).is_err());
1685 }
1686
1687 #[test]
1690 fn test_default_output_languages_is_empty() {
1691 let settings = Settings::default();
1692 assert!(
1693 settings.output_languages.is_empty(),
1694 "all channels should default to auto (empty map)"
1695 );
1696 }
1697
1698 #[test]
1699 fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1700 let mut settings = Settings::default();
1701 settings.version = 4;
1702 settings
1703 .output_languages
1704 .insert("response".to_string(), "ko".to_string());
1705 settings
1706 .output_languages
1707 .insert("commit_message".to_string(), "en".to_string());
1708
1709 let migrated = Settings::migrate(settings).unwrap();
1710 assert_eq!(migrated.version, SETTINGS_VERSION);
1711 assert_eq!(
1712 migrated.output_languages.get("response"),
1713 Some(&"ko".to_string())
1714 );
1715 assert_eq!(
1716 migrated.output_languages.get("commit_message"),
1717 Some(&"en".to_string())
1718 );
1719 }
1720
1721 #[test]
1722 fn test_migration_v4_to_v5_creates_empty_if_missing() {
1723 let mut settings = Settings::default();
1727 settings.version = 4;
1728 assert!(settings.output_languages.is_empty());
1729
1730 let migrated = Settings::migrate(settings).unwrap();
1731 assert_eq!(migrated.version, SETTINGS_VERSION);
1732 assert!(migrated.output_languages.is_empty());
1733 }
1734
1735 #[test]
1736 fn test_validate_keeps_user_defined_channel() {
1737 let mut settings = Settings::default();
1742 settings
1743 .output_languages
1744 .insert("pr_description".to_string(), "en".to_string()); settings
1746 .output_languages
1747 .insert("response".to_string(), "ko".to_string()); settings.validate_output_languages();
1750
1751 assert!(settings.output_languages.contains_key("pr_description"));
1752 assert!(settings.output_languages.contains_key("response"));
1753 assert_eq!(
1754 settings.output_languages.get("pr_description"),
1755 Some(&"en".to_string())
1756 );
1757 assert_eq!(
1758 settings.output_languages.get("response"),
1759 Some(&"ko".to_string())
1760 );
1761 }
1762
1763 #[test]
1764 fn test_validate_keeps_unknown_lang_with_warning() {
1765 let mut settings = Settings::default();
1766 settings
1767 .output_languages
1768 .insert("response".to_string(), "klingon".to_string()); settings
1770 .output_languages
1771 .insert("commit_message".to_string(), "en".to_string()); settings.validate_output_languages();
1774
1775 assert_eq!(
1778 settings.output_languages.get("response"),
1779 Some(&"klingon".to_string())
1780 );
1781 assert_eq!(
1782 settings.output_languages.get("commit_message"),
1783 Some(&"en".to_string())
1784 );
1785 }
1786
1787 #[test]
1788 fn test_known_channels_table_includes_core_four() {
1789 let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1790 assert!(keys.contains(&"response"));
1791 assert!(keys.contains(&"code_comment"));
1792 assert!(keys.contains(&"documentation"));
1793 assert!(keys.contains(&"commit_message"));
1794 }
1795
1796 #[test]
1797 fn test_known_langs_table_includes_auto_and_english() {
1798 let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1799 assert!(codes.contains(&"auto"));
1800 assert!(codes.contains(&"en"));
1801 }
1802
1803 #[test]
1804 fn test_default_language_policy_enabled_is_false() {
1805 let settings = Settings::default();
1807 assert!(
1808 !settings.language_policy_enabled,
1809 "language_policy_enabled must default to false (opt-in)"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_migration_v5_to_v6_defaults_master_toggle_to_off() {
1815 let mut settings = Settings::default();
1819 settings.version = 5;
1820 settings
1821 .output_languages
1822 .insert("response".to_string(), "ko".to_string());
1823 settings
1824 .output_languages
1825 .insert("commit_message".to_string(), "en".to_string());
1826
1827 let migrated = Settings::migrate(settings).unwrap();
1828 assert_eq!(migrated.version, SETTINGS_VERSION);
1829 assert!(
1830 !migrated.language_policy_enabled,
1831 "v5 → v6 migration must default language_policy_enabled to false"
1832 );
1833 assert_eq!(
1835 migrated.output_languages.get("response"),
1836 Some(&"ko".to_string())
1837 );
1838 assert_eq!(
1839 migrated.output_languages.get("commit_message"),
1840 Some(&"en".to_string())
1841 );
1842 }
1843
1844 #[test]
1845 fn test_default_glyph_set_is_unicode() {
1846 let settings = Settings::default();
1847 assert_eq!(
1848 settings.glyph_set,
1849 GlyphSet::Unicode,
1850 "glyph_set must default to Unicode"
1851 );
1852 }
1853
1854 #[test]
1855 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1856 let mut settings = Settings::default();
1859 settings.version = 7;
1860 settings.glyph_set = GlyphSet::default();
1862
1863 let migrated = Settings::migrate(settings).unwrap();
1864 assert_eq!(migrated.version, SETTINGS_VERSION);
1865 assert_eq!(
1866 migrated.glyph_set,
1867 GlyphSet::Unicode,
1868 "v7 → v8 migration must default glyph_set to unicode"
1869 );
1870 }
1871
1872 #[test]
1873 fn test_glyph_set_persists_through_roundtrip() {
1874 let mut original = Settings::default();
1878 original.glyph_set = GlyphSet::Nerd;
1879 let content = toml::to_string_pretty(&original).unwrap();
1880 assert!(
1881 content.contains("glyph_set = \"nerd\""),
1882 "nerd preset must serialize to snake_case; got:\n{content}"
1883 );
1884 let loaded: Settings = toml::from_str(&content).unwrap();
1885 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1886 original.glyph_set = GlyphSet::Unicode;
1888 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1889 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1890 }
1891
1892 #[test]
1893 fn test_save_and_load_roundtrip_preserves_language_policy_enabled() {
1894 let tmp = tempfile::tempdir().unwrap();
1895 let settings_path = tmp.path().join("settings.toml");
1896
1897 let mut original = Settings::default();
1898 original.language_policy_enabled = true;
1899 original
1900 .output_languages
1901 .insert("response".to_string(), "ko".to_string());
1902
1903 let content = toml::to_string_pretty(&original).unwrap();
1904 fs::write(&settings_path, &content).unwrap();
1905
1906 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1907 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1908
1909 assert!(loaded.language_policy_enabled);
1910 assert_eq!(
1911 loaded.output_languages.get("response"),
1912 Some(&"ko".to_string())
1913 );
1914 }
1915
1916 #[test]
1917 fn test_save_and_load_roundtrip_preserves_output_languages() {
1918 let tmp = tempfile::tempdir().unwrap();
1919 let settings_path = tmp.path().join("settings.toml");
1920
1921 let mut original = Settings::default();
1922 original
1923 .output_languages
1924 .insert("response".to_string(), "ko".to_string());
1925 original
1926 .output_languages
1927 .insert("commit_message".to_string(), "en".to_string());
1928
1929 let content = toml::to_string_pretty(&original).unwrap();
1930 fs::write(&settings_path, &content).unwrap();
1931
1932 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1933 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1934
1935 assert_eq!(
1936 loaded.output_languages.get("response"),
1937 Some(&"ko".to_string())
1938 );
1939 assert_eq!(
1940 loaded.output_languages.get("commit_message"),
1941 Some(&"en".to_string())
1942 );
1943 }
1944
1945 #[test]
1948 fn test_save_and_load_roundtrip() {
1949 let tmp = tempfile::tempdir().unwrap();
1950 let settings_path = tmp.path().join("settings.toml");
1951
1952 let mut original = Settings::default();
1953 original.last_used_model = Some("gpt-4o".to_string());
1954 original.last_used_provider = Some("openai".to_string());
1955 original.theme = "dracula".to_string();
1956 original.tool_timeout_seconds = 60;
1957
1958 let content = toml::to_string_pretty(&original).unwrap();
1960 fs::write(&settings_path, &content).unwrap();
1961
1962 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1964 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1965
1966 assert_eq!(loaded.last_used_model, original.last_used_model);
1967 assert_eq!(loaded.theme, original.theme);
1968 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1969 }
1970
1971 #[test]
1972 fn test_toml_roundtrip_preserves_new_fields() {
1973 let mut settings = Settings::default();
1974 settings.default_temperature = Some(0.8);
1975 settings.max_response_tokens = Some(8192);
1976 settings.auto_compaction = false;
1977 settings.extensions_enabled = false;
1978 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1979
1980 let toml_str = toml::to_string_pretty(&settings).unwrap();
1981 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1982
1983 assert_eq!(parsed.default_temperature, Some(0.8));
1984 assert_eq!(parsed.max_response_tokens, Some(8192));
1985 assert!(!parsed.auto_compaction);
1986 assert!(!parsed.extensions_enabled);
1987 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1988 }
1989
1990 #[test]
1993 fn test_json_roundtrip() {
1994 let mut settings = Settings::default();
1995 settings.last_used_model = Some("gpt-4o".to_string());
1996 settings.last_used_provider = Some("openai".to_string());
1997 settings.theme = "dracula".to_string();
1998 settings.tool_timeout_seconds = 60;
1999 settings.default_temperature = Some(0.8);
2000 settings.max_response_tokens = Some(8192);
2001
2002 let json_str = serde_json::to_string_pretty(&settings).unwrap();
2003 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
2004
2005 assert_eq!(parsed.last_used_model, settings.last_used_model);
2006 assert_eq!(parsed.theme, settings.theme);
2007 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
2008 assert_eq!(parsed.default_temperature, settings.default_temperature);
2009 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
2010 }
2011
2012 #[test]
2013 fn test_json_serialize_for_format() {
2014 let mut settings = Settings::default();
2015 settings.last_used_model = Some("claude-3".to_string());
2016 settings.last_used_provider = Some("anthropic".to_string());
2017 settings.thinking_level = ThinkingLevel::Minimal;
2018
2019 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
2020 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
2021
2022 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
2023 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
2024 }
2025
2026 #[test]
2027 fn test_toml_serialize_for_format() {
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.thinking_level = ThinkingLevel::High;
2032
2033 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
2034 let parsed: Settings = toml::from_str(&toml_content).unwrap();
2035
2036 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2037 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
2038 }
2039
2040 #[test]
2041 fn test_parse_from_str_json() {
2042 let json_content = r#"{
2043 "last_used_model": "gpt-4",
2044 "last_used_provider": "openai",
2045 "theme": "nord",
2046 "tool_timeout_seconds": 90
2047 }"#;
2048
2049 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
2050 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
2051 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
2052 assert_eq!(settings.theme, "nord");
2053 assert_eq!(settings.tool_timeout_seconds, 90);
2054 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2056 assert!(settings.extensions_enabled);
2057 }
2058
2059 #[test]
2060 fn test_parse_from_str_toml() {
2061 let toml_content = r#"
2062last_used_model = "claude-opus"
2063last_used_provider = "anthropic"
2064theme = "monokai"
2065tool_timeout_seconds = 45
2066"#;
2067
2068 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
2069 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
2070 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
2071 assert_eq!(settings.theme, "monokai");
2072 assert_eq!(settings.tool_timeout_seconds, 45);
2073 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2074 }
2075
2076 #[test]
2077 fn test_layer_file_json() {
2078 let base = Settings::default();
2079
2080 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2081 let json_content = r#"{
2082 "last_used_model": "gpt-4o",
2083 "last_used_provider": "openai",
2084 "theme": "dracula",
2085 "auto_compaction": false
2086 }"#;
2087 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2088
2089 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2090 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
2091 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
2092 assert_eq!(merged.theme, "dracula");
2093 assert!(!merged.auto_compaction);
2094 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
2096 assert!(merged.extensions_enabled);
2097 assert_eq!(merged.tool_timeout_seconds, 120);
2098 }
2099
2100 #[test]
2101 fn test_layer_file_json_preserves_unset() {
2102 let mut base = Settings::default();
2103 base.last_used_provider = Some("deepseek".to_string());
2104
2105 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2106 let json_content = r#"{ "theme": "nord" }"#;
2107 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2108
2109 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2110 assert_eq!(merged.theme, "nord");
2111 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
2112 }
2113
2114 #[test]
2115 fn test_save_to_json() {
2116 let tmp = tempfile::tempdir().unwrap();
2117 let settings_path = tmp.path().join("settings.json");
2118
2119 let mut settings = Settings::default();
2120 settings.last_used_model = Some("gpt-4o".to_string());
2121 settings.last_used_provider = Some("openai".to_string());
2122 settings.theme = "dracula".to_string();
2123 settings.tool_timeout_seconds = 60;
2124
2125 settings.save_to(&settings_path).unwrap();
2126
2127 let content = fs::read_to_string(&settings_path).unwrap();
2129 let parsed: Settings = serde_json::from_str(&content).unwrap();
2130 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
2131 assert_eq!(parsed.theme, "dracula");
2132 assert_eq!(parsed.tool_timeout_seconds, 60);
2133 }
2134
2135 #[test]
2136 fn test_save_to_toml() {
2137 let tmp = tempfile::tempdir().unwrap();
2138 let settings_path = tmp.path().join("settings.toml");
2139
2140 let mut settings = Settings::default();
2141 settings.last_used_model = Some("gemini-pro".to_string());
2142 settings.last_used_provider = Some("google".to_string());
2143 settings.theme = "monokai".to_string();
2144 settings.tool_timeout_seconds = 90;
2145
2146 settings.save_to(&settings_path).unwrap();
2147
2148 let content = fs::read_to_string(&settings_path).unwrap();
2150 let parsed: Settings = toml::from_str(&content).unwrap();
2151 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2152 assert_eq!(parsed.theme, "monokai");
2153 assert_eq!(parsed.tool_timeout_seconds, 90);
2154 }
2155
2156 #[test]
2157 fn test_load_from_dir_with_json_project_config() {
2158 let _guard = EnvGuard::new(&[
2159 "OXI_MODEL",
2160 "OXI_PROVIDER",
2161 "OXI_THEME",
2162 "OXI_TOOL_TIMEOUT",
2163 "OXI_TEMPERATURE",
2164 "OXI_MAX_TOKENS",
2165 "OXI_SESSION_DIR",
2166 "OXI_STREAM",
2167 "OXI_EXTENSIONS_ENABLED",
2168 ]);
2169 let tmp = tempfile::tempdir().unwrap();
2170 let oxi_dir = tmp.path().join(".oxi");
2171 fs::create_dir_all(&oxi_dir).unwrap();
2172 let settings_path = oxi_dir.join("settings.json");
2173 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
2175 fs::write(&settings_path, json_content).unwrap();
2176
2177 let settings = Settings::load_from(tmp.path()).unwrap();
2178 assert_eq!(
2180 settings.last_used_model,
2181 Some("gemini-2.0-flash".to_string())
2182 );
2183 assert_eq!(settings.last_used_provider, Some("google".to_string()));
2184 }
2185
2186 #[test]
2187 fn test_find_project_settings_json_priority() {
2188 let tmp = tempfile::tempdir().unwrap();
2189 let oxi_dir = tmp.path().join(".oxi");
2190 fs::create_dir_all(&oxi_dir).unwrap();
2191
2192 let json_path = oxi_dir.join("settings.json");
2194 let toml_path = oxi_dir.join("settings.toml");
2195 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2196 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2197
2198 let found = Settings::find_project_settings(tmp.path());
2200 assert!(found.is_some());
2201 assert_eq!(
2202 found.unwrap().file_name().unwrap().to_str().unwrap(),
2203 "settings.json"
2204 );
2205 }
2206
2207 #[test]
2208 fn test_find_project_settings_json_only() {
2209 let tmp = tempfile::tempdir().unwrap();
2210 let oxi_dir = tmp.path().join(".oxi");
2211 fs::create_dir_all(&oxi_dir).unwrap();
2212
2213 let json_path = oxi_dir.join("settings.json");
2214 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2215
2216 let found = Settings::find_project_settings(tmp.path());
2217 assert!(found.is_some());
2218 assert_eq!(
2219 found.unwrap().file_name().unwrap().to_str().unwrap(),
2220 "settings.json"
2221 );
2222 }
2223
2224 #[test]
2225 fn test_find_project_settings_toml_fallback() {
2226 let tmp = tempfile::tempdir().unwrap();
2227 let oxi_dir = tmp.path().join(".oxi");
2228 fs::create_dir_all(&oxi_dir).unwrap();
2229
2230 let toml_path = oxi_dir.join("settings.toml");
2231 fs::write(&toml_path, r#"theme = "test""#).unwrap();
2232
2233 let found = Settings::find_project_settings(tmp.path());
2234 assert!(found.is_some());
2235 assert_eq!(
2236 found.unwrap().file_name().unwrap().to_str().unwrap(),
2237 "settings.toml"
2238 );
2239 }
2240
2241 #[test]
2242 fn test_detect_format() {
2243 let json_path = PathBuf::from("/test/settings.json");
2244 let toml_path = PathBuf::from("/test/settings.toml");
2245 let unknown_path = PathBuf::from("/test/settings");
2246
2247 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2248 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2249 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2250 }
2252
2253 #[test]
2254 fn test_settings_format_extension() {
2255 assert_eq!(SettingsFormat::Json.extension(), "json");
2256 assert_eq!(SettingsFormat::Toml.extension(), "toml");
2257 }
2258
2259 #[test]
2260 fn test_layer_json_over_toml() {
2261 let tmp = tempfile::tempdir().unwrap();
2263 let oxi_dir = tmp.path().join(".oxi");
2264 fs::create_dir_all(&oxi_dir).unwrap();
2265
2266 let json_path = oxi_dir.join("settings.json");
2267 let toml_path = oxi_dir.join("settings.toml");
2268
2269 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2271 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2273
2274 let settings = Settings::load_from(tmp.path()).unwrap();
2276 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2277 }
2278
2279 #[test]
2280 fn test_mixed_format_loading() {
2281 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2283 let toml_content = r#"
2284last_used_model = "loaded-via-toml"
2285theme = "loaded-theme"
2286stream_responses = false
2287"#;
2288 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2289
2290 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2291 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2292 assert_eq!(merged.theme, "loaded-theme");
2293 assert!(!merged.stream_responses);
2294 }
2295
2296 #[test]
2297 fn test_merge_json_values() {
2298 let base = serde_json::json!({
2299 "version": 1,
2300 "theme": "default",
2301 "extensions": ["ext1"],
2302 "nested": {
2303 "a": 1,
2304 "b": 2
2305 }
2306 });
2307
2308 let override_ = serde_json::json!({
2309 "version": 2,
2310 "theme": "dark",
2311 "extensions": ["ext2"],
2312 "nested": {
2313 "b": 20,
2314 "c": 30
2315 }
2316 });
2317
2318 let merged = merge_json_values(base, override_);
2319
2320 assert_eq!(merged["version"], 2);
2321 assert_eq!(merged["theme"], "dark");
2322 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2324 assert_eq!(merged["nested"]["a"], 1);
2326 assert_eq!(merged["nested"]["b"], 20);
2327 assert_eq!(merged["nested"]["c"], 30);
2328 }
2329
2330 #[test]
2331 fn test_save_project_preserves_existing_format() {
2332 let tmp = tempfile::tempdir().unwrap();
2333 let oxi_dir = tmp.path().join(".oxi");
2334 fs::create_dir_all(&oxi_dir).unwrap();
2335
2336 let toml_path = oxi_dir.join("settings.toml");
2338 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2339
2340 let mut settings = Settings::default();
2341 settings.theme = "new-theme".to_string();
2342 settings.save_project(tmp.path()).unwrap();
2343
2344 let content = fs::read_to_string(&toml_path).unwrap();
2346 assert!(content.contains("new-theme"));
2347 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2348 }
2349
2350 #[test]
2351 fn test_save_project_creates_json_by_default() {
2352 let tmp = tempfile::tempdir().unwrap();
2353 let oxi_dir = tmp.path().join(".oxi");
2354 fs::create_dir_all(&oxi_dir).unwrap();
2355 let mut settings = Settings::default();
2358 settings.theme = "json-theme".to_string();
2359 settings.save_project(tmp.path()).unwrap();
2360
2361 let json_path = oxi_dir.join("settings.json");
2363 assert!(json_path.exists());
2364 let content = fs::read_to_string(&json_path).unwrap();
2365 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2366 assert!(content.contains("json-theme"));
2367 }
2368
2369 #[test]
2372 fn test_custom_provider_default_api() {
2373 use super::CustomProvider;
2374 let cp = CustomProvider {
2375 name: "test".to_string(),
2376 base_url: "https://api.test.com/v1".to_string(),
2377 api_key_env: "TEST_API_KEY".to_string(),
2378 api: super::default_custom_provider_api(),
2379 };
2380 assert_eq!(cp.api, "openai-completions");
2381 }
2382
2383 #[test]
2384 fn test_custom_provider_toml_deserialize() {
2385 let toml_content = r#"
2386[[custom_providers]]
2387name = "minimax"
2388base_url = "https://api.minimax.chat/v1"
2389api_key_env = "MINIMAX_API_KEY"
2390api = "openai-completions"
2391
2392[[custom_providers]]
2393name = "zai"
2394base_url = "https://api.z.ai/v1"
2395api_key_env = "ZAI_API_KEY"
2396api = "openai-responses"
2397"#;
2398 let settings: Settings = toml::from_str(toml_content).unwrap();
2399 assert_eq!(settings.custom_providers.len(), 2);
2400 assert_eq!(settings.custom_providers[0].name, "minimax");
2401 assert_eq!(
2402 settings.custom_providers[0].base_url,
2403 "https://api.minimax.chat/v1"
2404 );
2405 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2406 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2407 assert_eq!(settings.custom_providers[1].name, "zai");
2408 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2409 }
2410
2411 #[test]
2412 fn test_custom_provider_json_deserialize() {
2413 let json_content = r#"{
2414 "custom_providers": [
2415 {
2416 "name": "minimax",
2417 "base_url": "https://api.minimax.chat/v1",
2418 "api_key_env": "MINIMAX_API_KEY",
2419 "api": "openai-completions"
2420 }
2421 ]
2422 }"#;
2423 let settings: Settings = serde_json::from_str(json_content).unwrap();
2424 assert_eq!(settings.custom_providers.len(), 1);
2425 assert_eq!(settings.custom_providers[0].name, "minimax");
2426 }
2427
2428 #[test]
2429 fn test_custom_provider_toml_roundtrip() {
2430 let mut settings = Settings::default();
2431 settings.custom_providers.push(super::CustomProvider {
2432 name: "test".to_string(),
2433 base_url: "https://api.test.com/v1".to_string(),
2434 api_key_env: "TEST_API_KEY".to_string(),
2435 api: "openai-completions".to_string(),
2436 });
2437
2438 let toml_str = toml::to_string_pretty(&settings).unwrap();
2439 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2440 assert_eq!(parsed.custom_providers.len(), 1);
2441 assert_eq!(parsed.custom_providers[0].name, "test");
2442 assert_eq!(
2443 parsed.custom_providers[0].base_url,
2444 "https://api.test.com/v1"
2445 );
2446 }
2447
2448 #[test]
2449 fn test_custom_provider_defaults_empty() {
2450 let settings = Settings::default();
2451 assert!(settings.custom_providers.is_empty());
2452 }
2453
2454 #[test]
2455 fn test_custom_provider_layer_file() {
2456 let base = Settings::default();
2457
2458 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2459 let toml_content = r#"
2460[[custom_providers]]
2461name = "my-provider"
2462base_url = "https://api.my-provider.com/v1"
2463api_key_env = "MY_PROVIDER_API_KEY"
2464"#;
2465 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2466
2467 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2468 assert_eq!(merged.custom_providers.len(), 1);
2469 assert_eq!(merged.custom_providers[0].name, "my-provider");
2470 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2472 }
2473}