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 = 9;
39
40pub const KNOWN_CHANNELS: &[(&str, &str)] = &[
51 ("response", "Your conversational responses to the user"),
52 (
53 "code_comment",
54 "Code comments you write (//, /* */, #, etc.)",
55 ),
56 (
57 "documentation",
58 "Documentation (markdown files, README, AGENTS.md, doc comments)",
59 ),
60 ("commit_message", "Git commit messages (subject + body)"),
61];
62
63pub const KNOWN_LANGS: &[(&str, &str)] = &[
74 ("auto", "Auto (match user)"),
75 ("en", "English"),
76 ("ko", "Korean (한국어)"),
77 ("ja", "Japanese (日本語)"),
78 ("zh", "Chinese (中文)"),
79 ("es", "Spanish"),
80 ("fr", "French"),
81 ("de", "German"),
82];
83
84#[allow(dead_code)]
87const ENV_PREFIX: &str = "OXI_";
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
91#[serde(rename_all = "snake_case")]
92pub enum ThinkingLevel {
93 #[default]
95 Off,
96 Minimal,
98 Low,
100 Medium,
102 High,
104 XHigh,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
114#[serde(rename_all = "snake_case")]
115pub enum EditFormat {
116 #[default]
118 Hashline,
119 StrReplace,
121}
122#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct CustomProvider {
128 pub name: String,
130 pub base_url: String,
132 pub api_key_env: String,
134 #[serde(default = "default_custom_provider_api")]
136 pub api: String,
137}
138
139fn default_custom_provider_api() -> String {
140 "openai-completions".to_string()
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct Settings {
146 #[serde(default)]
149 pub version: u32,
150
151 #[serde(default = "default_thinking_level")]
154 pub thinking_level: ThinkingLevel,
155 #[serde(default = "default_theme")]
157 pub theme: String,
158
159 #[serde(default)]
166 pub glyph_set: GlyphSet,
167
168 #[serde(default, skip_serializing)]
170 pub default_model: Option<String>,
171
172 #[serde(default, skip_serializing)]
174 pub default_provider: Option<String>,
175
176 #[serde(default)]
179 pub last_used_model: Option<String>,
180
181 #[serde(default)]
183 pub last_used_provider: Option<String>,
184
185 pub max_tokens: Option<u32>,
187
188 pub temperature: Option<f32>,
190
191 pub default_temperature: Option<f64>,
193
194 pub max_response_tokens: Option<usize>,
196
197 #[serde(default = "default_session_history_size")]
200 pub session_history_size: usize,
201
202 pub session_dir: Option<PathBuf>,
204
205 #[serde(default = "default_true")]
208 pub stream_responses: bool,
209
210 #[serde(default = "default_true")]
212 pub extensions_enabled: bool,
213
214 #[serde(default = "default_true")]
216 pub auto_compaction: bool,
217
218 #[serde(default)]
221 pub disabled_tools: Vec<String>,
222
223 #[serde(default = "default_tool_timeout")]
226 pub tool_timeout_seconds: u64,
227
228 #[serde(default, alias = "questionnaire_timeout_secs")]
231 pub ask_timeout_secs: u64,
232
233 #[serde(default)]
236 pub extensions: Vec<String>,
237
238 #[serde(default)]
240 pub skills: Vec<String>,
241
242 #[serde(default)]
244 pub prompts: Vec<String>,
245
246 #[serde(default)]
248 pub themes: Vec<String>,
249
250 #[serde(default)]
253 pub custom_providers: Vec<CustomProvider>,
254
255 #[serde(default)]
260 pub dynamic_models: HashMap<String, Vec<String>>,
261
262 #[serde(default = "default_false")]
265 pub enable_routing: bool,
266
267 #[serde(default)]
269 pub router_profile: Option<String>,
270
271 #[serde(default = "default_true")]
273 pub prefer_cost_efficient: bool,
274
275 #[serde(default)]
277 pub fallback_chain: Vec<String>,
278
279 #[serde(default = "default_true")]
281 pub enable_fallback: bool,
282
283 #[serde(default)]
285 pub disable_fallback: bool,
286
287 #[serde(default = "default_circuit_failure_threshold")]
289 pub circuit_breaker_failure_threshold: u32,
290
291 #[serde(default = "default_circuit_open_duration_secs")]
293 pub circuit_breaker_open_duration_secs: u64,
294
295 #[serde(default)]
300 pub keybindings: HashMap<String, Vec<String>>,
301
302 #[serde(default)]
348 pub output_languages: HashMap<String, String>,
349
350 #[serde(default = "default_false")]
369 pub language_policy_enabled: bool,
370
371 #[serde(default)]
376 pub edit_format: EditFormat,
377
378 #[serde(default = "default_false")]
382 pub memory_enabled: bool,
383
384 #[serde(default)]
387 pub memory_db_path: Option<PathBuf>,
388
389 #[serde(default = "default_false")]
393 pub ttsr_enabled: bool,
394
395 #[serde(default = "default_ttsr_mode")]
397 pub ttsr_interrupt_mode: String,
398
399 #[serde(default)]
410 pub model_roles: HashMap<String, String>,
411}
412
413fn default_theme() -> String {
414 "default".to_string()
415}
416
417fn default_thinking_level() -> ThinkingLevel {
418 ThinkingLevel::Medium
419}
420
421fn default_session_history_size() -> usize {
422 100
423}
424
425fn default_true() -> bool {
426 true
427}
428
429fn default_false() -> bool {
430 false
431}
432
433fn default_ttsr_mode() -> String {
434 "prose_only".to_string()
435}
436
437fn default_circuit_failure_threshold() -> u32 {
438 5
439}
440
441fn default_circuit_open_duration_secs() -> u64 {
442 30
443}
444
445fn default_tool_timeout() -> u64 {
446 120
447}
448
449impl Default for Settings {
450 fn default() -> Self {
451 Self {
452 version: SETTINGS_VERSION,
453 thinking_level: ThinkingLevel::Medium,
454 theme: default_theme(),
455 glyph_set: GlyphSet::default(),
456 last_used_model: None,
457 last_used_provider: None,
458 default_model: None,
459 default_provider: None,
460 max_tokens: None,
461 temperature: None,
462 default_temperature: None,
463 max_response_tokens: None,
464 session_history_size: default_session_history_size(),
465 session_dir: None,
466 stream_responses: true,
467 extensions_enabled: true,
468 auto_compaction: true,
469 disabled_tools: Vec::new(),
470 tool_timeout_seconds: default_tool_timeout(),
471 ask_timeout_secs: 0,
472 extensions: Vec::new(),
473 skills: Vec::new(),
474 prompts: Vec::new(),
475 themes: Vec::new(),
476 custom_providers: Vec::new(),
477 dynamic_models: HashMap::new(),
478 enable_routing: false,
480 router_profile: None,
481 prefer_cost_efficient: true,
482 fallback_chain: Vec::new(),
483 enable_fallback: true,
484 disable_fallback: false,
485 circuit_breaker_failure_threshold: 5,
486 circuit_breaker_open_duration_secs: 30,
487 keybindings: HashMap::new(),
488 output_languages: HashMap::new(),
489 language_policy_enabled: false,
490 edit_format: EditFormat::default(),
491 memory_enabled: false,
492 memory_db_path: None,
493 ttsr_enabled: false,
494 ttsr_interrupt_mode: default_ttsr_mode(),
495 model_roles: HashMap::new(),
496 }
497 }
498}
499
500impl Settings {
501 pub fn settings_dir() -> Result<PathBuf> {
505 let base = dirs::home_dir().context("Cannot determine home directory")?;
506 Ok(base.join(".oxi"))
507 }
508
509 pub fn settings_toml_path() -> Result<PathBuf> {
511 Ok(Self::settings_dir()?.join("settings.toml"))
512 }
513
514 pub fn settings_json_path() -> Result<PathBuf> {
516 Ok(Self::settings_dir()?.join("settings.json"))
517 }
518
519 pub fn settings_path() -> Result<PathBuf> {
526 let json_path = Self::settings_json_path()?;
527 let toml_path = Self::settings_toml_path()?;
528
529 if json_path.exists() && toml_path.exists() {
530 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
532 return Ok(json_path);
533 }
534
535 if json_path.exists() {
536 return Ok(json_path);
537 }
538
539 if toml_path.exists() {
540 return Ok(toml_path);
541 }
542
543 Ok(json_path)
545 }
546
547 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
552 let json_path = Self::settings_json_path()?;
553 let toml_path = Self::settings_toml_path()?;
554
555 let (primary, secondary) = if prefer_json {
556 (&json_path, &toml_path)
557 } else {
558 (&toml_path, &json_path)
559 };
560
561 if primary.exists() {
562 return Ok(primary.clone());
563 }
564
565 if secondary.exists() {
566 return Ok(secondary.clone());
567 }
568
569 Ok(primary.clone())
571 }
572
573 pub fn detect_format(path: &Path) -> SettingsFormat {
575 match path.extension().and_then(|e| e.to_str()) {
576 Some("json") => SettingsFormat::Json,
577 Some("toml") => SettingsFormat::Toml,
578 _ => SettingsFormat::Json, }
580 }
581
582 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
587 let mut dir = start_dir.to_path_buf();
588 loop {
589 let json_candidate = dir.join(".oxi").join("settings.json");
591 if json_candidate.exists() {
592 return Some(json_candidate);
593 }
594
595 let toml_candidate = dir.join(".oxi").join("settings.toml");
596 if toml_candidate.exists() {
597 return Some(toml_candidate);
598 }
599
600 if !dir.pop() {
601 return None;
602 }
603 }
604 }
605
606 pub fn effective_session_dir(&self) -> Result<PathBuf> {
610 if let Some(ref dir) = self.session_dir {
611 return Ok(dir.clone());
612 }
613 Ok(Self::settings_dir()?.join("sessions"))
614 }
615
616 pub fn load() -> Result<Self> {
634 Self::load_from_cwd()
635 }
636
637 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
643 Self::load_from_with(dir, None)
644 }
645
646 pub fn load_from_with(
665 dir: &std::path::Path,
666 global_override: Option<&std::path::Path>,
667 ) -> Result<Self> {
668 let mut settings = Settings::default();
670
671 let resolved_global: Option<std::path::PathBuf> = match global_override {
674 Some(p) => Some(p.to_path_buf()),
675 None => Self::settings_path().ok(),
676 };
677 if let Some(ref gp) = resolved_global
678 && gp.exists()
679 {
680 settings = Self::layer_file(&settings, gp)?;
681 }
682
683 if let Some(project_path) = Self::find_project_settings(dir) {
685 settings = Self::layer_file(&settings, &project_path)?;
686 }
687
688 settings.apply_env();
690
691 settings = Self::migrate(settings)?;
693
694 settings.validate_output_languages();
696
697 Ok(settings)
698 }
699
700 fn validate_output_languages(&mut self) {
707 if self.output_languages.is_empty() {
708 return;
709 }
710 let known_langs: std::collections::HashSet<&str> =
711 KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
712
713 for (channel, lang) in &self.output_languages {
714 if !known_langs.contains(lang.as_str()) {
715 tracing::warn!(
716 "Unknown output_languages language code '{}' for channel '{}'. \
717 Keeping as-is (the model will likely understand).",
718 lang,
719 channel
720 );
721 }
722 }
723 }
724
725 pub fn load_from_cwd() -> Result<Self> {
727 let cwd = env::current_dir().context("Cannot determine current directory")?;
728 Self::load_from(&cwd)
729 }
730
731 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
737 let content = fs::read_to_string(path)
738 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
739
740 let format = Self::detect_format(path);
741 let overlay: serde_json::Value = match format {
742 SettingsFormat::Toml => {
743 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
744 format!("Failed to parse TOML settings from {}", path.display())
745 })?;
746 toml_value_to_json(toml_value)
748 }
749 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
750 format!("Failed to parse JSON settings from {}", path.display())
751 })?,
752 };
753
754 let base_json =
758 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
759
760 let merged = merge_json_values(base_json, overlay);
761 let result: Settings =
762 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
763
764 Ok(result)
765 }
766
767 #[allow(dead_code)]
793 pub fn apply_env(&mut self) {
794 }
798
799 #[allow(dead_code)]
805 pub fn from_env() -> Self {
806 Self::default()
807 }
808
809 pub fn save(&self) -> Result<()> {
816 let dir = Self::settings_dir()?;
817 let path = Self::settings_path()?;
818
819 if !dir.exists() {
820 fs::create_dir_all(&dir).with_context(|| {
821 format!("Failed to create settings directory {}", dir.display())
822 })?;
823 }
824
825 let format = Self::detect_format(&path);
826 let content = Self::serialize_for_format(self, format)?;
827
828 let tmp_path = path.with_extension("tmp");
830 fs::write(&tmp_path, &content)
831 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
832 fs::rename(&tmp_path, &path)
833 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
834
835 Ok(())
836 }
837
838 pub fn save_to(&self, path: &Path) -> Result<()> {
840 if let Some(parent) = path.parent()
841 && !parent.exists()
842 {
843 fs::create_dir_all(parent)
844 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
845 }
846
847 let format = Self::detect_format(path);
848 let content = Self::serialize_for_format(self, format)?;
849
850 let tmp_path = path.with_extension("tmp");
852 fs::write(&tmp_path, &content)
853 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
854 fs::rename(&tmp_path, path)
855 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
856
857 Ok(())
858 }
859
860 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
864 let dir = project_dir.join(".oxi");
865
866 if !dir.exists() {
867 fs::create_dir_all(&dir).with_context(|| {
868 format!(
869 "Failed to create project settings directory {}",
870 dir.display()
871 )
872 })?;
873 }
874
875 let json_path = dir.join("settings.json");
877 let toml_path = dir.join("settings.toml");
878
879 let path = if json_path.exists() {
880 &json_path
881 } else if toml_path.exists() {
882 &toml_path
883 } else {
884 &json_path
886 };
887
888 let format = Self::detect_format(path);
889 let content = Self::serialize_for_format(self, format)?;
890
891 let tmp_path = path.with_extension("tmp");
893 fs::write(&tmp_path, &content)
894 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
895 fs::rename(&tmp_path, path)
896 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
897
898 Ok(())
899 }
900
901 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
903 match format {
904 SettingsFormat::Toml => {
905 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
906 }
907 SettingsFormat::Json => serde_json::to_string_pretty(settings)
908 .context("Failed to serialize settings to JSON"),
909 }
910 }
911
912 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
914 match format {
915 SettingsFormat::Toml => {
916 toml::from_str(content).context("Failed to parse TOML settings")
917 }
918 SettingsFormat::Json => {
919 serde_json::from_str(content).context("Failed to parse JSON settings")
920 }
921 }
922 }
923
924 pub fn merge_cli(
937 &mut self,
938 model: Option<String>,
939 provider: Option<String>,
940 enable_routing: Option<bool>,
941 prefer_cost_efficient: Option<bool>,
942 fallback_chain: Option<Vec<String>>,
943 disable_fallback: Option<bool>,
944 ) {
945 if let Some(m) = model {
946 self.last_used_model = Some(m);
947 }
948 if let Some(p) = provider {
949 self.last_used_provider = Some(p);
950 }
951 if let Some(r) = enable_routing {
952 self.enable_routing = r;
953 }
954 if let Some(p) = prefer_cost_efficient {
955 self.prefer_cost_efficient = p;
956 }
957 if let Some(fc) = fallback_chain
958 && !fc.is_empty()
959 {
960 self.fallback_chain = fc;
961 }
962 if let Some(df) = disable_fallback {
963 self.disable_fallback = df;
964 if df {
966 self.enable_fallback = false;
967 }
968 }
969 }
970
971 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
974 cli_model.map(String::from).or_else(|| {
975 let model = self.last_used_model.as_ref()?;
980 if model.contains('/') {
981 Some(model.clone())
983 } else if let Some(ref provider) = self.last_used_provider {
984 Some(format!("{}/{}", provider, model))
986 } else {
987 Some(model.clone())
988 }
989 })
990 }
991
992 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
995 cli_provider
996 .map(String::from)
997 .or_else(|| self.last_used_provider.clone())
998 }
999
1000 pub fn effective_temperature(&self) -> Option<f64> {
1003 self.default_temperature
1004 .or(self.temperature.map(|t| t as f64))
1005 }
1006
1007 pub fn effective_max_tokens(&self) -> Option<usize> {
1010 self.max_response_tokens
1011 .or(self.max_tokens.map(|t| t as usize))
1012 }
1013
1014 pub fn router_profile(&self) -> Option<&str> {
1016 self.router_profile.as_deref()
1017 }
1018
1019 pub fn save_last_used(model_id: &str) {
1025 if let Ok(mut settings) = Self::load() {
1026 if let Some((provider, model)) = model_id.split_once('/') {
1027 settings.last_used_provider = Some(provider.to_string());
1028 settings.last_used_model = Some(model.to_string());
1029 } else {
1030 settings.last_used_model = Some(model_id.to_string());
1031 }
1032 let _ = settings.save();
1033 }
1034 }
1035
1036 pub fn save_theme(&mut self, name: &str) -> Result<()> {
1038 self.theme = name.to_string();
1039 self.save()
1040 }
1041
1042 pub fn get_theme_name(&self) -> String {
1044 if self.theme.is_empty() || self.theme == "default" {
1045 "oxi_dark".to_string()
1046 } else {
1047 self.theme.clone()
1048 }
1049 }
1050
1051 fn migrate(settings: Settings) -> Result<Settings> {
1067 let mut settings = settings;
1068
1069 match settings.version {
1070 SETTINGS_VERSION => {
1071 }
1073 0 => {
1074 if settings.tool_timeout_seconds == 0 {
1077 settings.tool_timeout_seconds = default_tool_timeout();
1078 }
1079 settings.version = SETTINGS_VERSION;
1080
1081 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1082 }
1083 1 | 2 => {
1084 settings.version = SETTINGS_VERSION;
1089 tracing::info!(
1090 "Migrated settings from version {} to {} (dynamic_models + output_languages + language_policy_enabled defaults applied)",
1091 settings.version,
1092 SETTINGS_VERSION
1093 );
1094 }
1095 3 => {
1096 if let Some(model) = settings.default_model.take() {
1098 if let Some((provider, model_name)) = model.split_once('/') {
1099 settings.last_used_provider = Some(provider.to_string());
1100 settings.last_used_model = Some(model_name.to_string());
1101 } else {
1102 settings.last_used_model = Some(model);
1103 }
1104 }
1105 settings.version = SETTINGS_VERSION;
1107 tracing::info!(
1108 "Migrated settings from version 3 to {} (default_model → last_used_model; output_languages + language_policy_enabled defaults)",
1109 SETTINGS_VERSION
1110 );
1111 }
1112 4 => {
1113 settings.version = SETTINGS_VERSION;
1117 tracing::info!(
1118 "Migrated settings from version 4 to {} (added output_languages + language_policy_enabled, both defaulted to off)",
1119 SETTINGS_VERSION
1120 );
1121 }
1122 5 => {
1123 settings.version = SETTINGS_VERSION;
1129 tracing::info!(
1130 "Migrated settings from version 5 to {} (added language_policy_enabled, defaulting to OFF — toggle ON in /settings to activate existing channels)",
1131 SETTINGS_VERSION
1132 );
1133 }
1134 6 => {
1135 settings.version = SETTINGS_VERSION;
1138 tracing::info!(
1139 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1140 SETTINGS_VERSION
1141 );
1142 }
1143 7 => {
1144 settings.version = SETTINGS_VERSION;
1147 tracing::info!(
1148 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1149 SETTINGS_VERSION
1150 );
1151 }
1152 8 => {
1153 settings.version = SETTINGS_VERSION;
1156 tracing::info!(
1157 "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1158 SETTINGS_VERSION
1159 );
1160 }
1161 v if v > SETTINGS_VERSION => {
1162 anyhow::bail!(
1164 "Settings version {} is newer than supported version {}. \
1165 Please update oxi.",
1166 v,
1167 SETTINGS_VERSION
1168 );
1169 }
1170 v => {
1171 tracing::warn!(
1173 "Unknown settings version {}, attempting migration to {}",
1174 v,
1175 SETTINGS_VERSION
1176 );
1177 settings.version = SETTINGS_VERSION;
1178 }
1179 }
1180
1181 Ok(settings)
1182 }
1183}
1184
1185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1189pub enum SettingsFormat {
1190 #[default]
1192 Json,
1193 Toml,
1195}
1196
1197impl SettingsFormat {
1198 pub fn extension(&self) -> &'static str {
1200 match self {
1201 SettingsFormat::Json => "json",
1202 SettingsFormat::Toml => "toml",
1203 }
1204 }
1205}
1206
1207fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1211 match toml {
1212 toml::Value::String(s) => serde_json::Value::String(s),
1213 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1214 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1215 .map(serde_json::Value::Number)
1216 .unwrap_or(serde_json::Value::Null),
1217 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1218 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1219 toml::Value::Array(arr) => {
1220 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1221 }
1222 toml::Value::Table(table) => {
1223 let obj = table
1224 .into_iter()
1225 .map(|(k, v)| (k, toml_value_to_json(v)))
1226 .collect();
1227 serde_json::Value::Object(obj)
1228 }
1229 }
1230}
1231
1232fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1234 match (base, override_) {
1235 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1237 let mut result = base_map;
1238 for (key, override_value) in override_map {
1239 let base_value = result.remove(&key);
1240 let merged = match base_value {
1241 Some(base_v) => merge_json_values(base_v, override_value),
1242 None => override_value,
1243 };
1244 result.insert(key, merged);
1245 }
1246 serde_json::Value::Object(result)
1247 }
1248 (_, override_) => override_,
1250 }
1251}
1252
1253pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1255 match s.to_lowercase().as_str() {
1256 "off" | "none" => Some(ThinkingLevel::Off),
1257 "minimal" => Some(ThinkingLevel::Minimal),
1258 "low" => Some(ThinkingLevel::Low),
1259 "medium" | "standard" => Some(ThinkingLevel::Medium),
1260 "high" | "thorough" => Some(ThinkingLevel::High),
1261 "xhigh" => Some(ThinkingLevel::XHigh),
1262 _ => None,
1263 }
1264}
1265
1266#[allow(dead_code)]
1268fn parse_boolish(s: &str) -> Result<bool> {
1269 match s.to_lowercase().as_str() {
1270 "true" | "1" | "yes" | "on" => Ok(true),
1271 "false" | "0" | "no" | "off" => Ok(false),
1272 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1273 }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use std::io::Write as IoWrite;
1280 use std::sync::Mutex;
1281
1282 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1285
1286 struct EnvGuard {
1289 saved: Vec<(String, Option<String>)>,
1290 }
1291
1292 impl EnvGuard {
1293 fn new(vars: &[&str]) -> Self {
1294 let saved = vars
1295 .iter()
1296 .map(|&name| {
1297 let old = env::var(name).ok();
1298 unsafe { env::remove_var(name) };
1300 (name.to_string(), old)
1301 })
1302 .collect();
1303 Self { saved }
1304 }
1305 }
1306
1307 impl Drop for EnvGuard {
1308 fn drop(&mut self) {
1309 for (name, old) in self.saved.drain(..) {
1310 match old {
1311 Some(val) => unsafe { env::set_var(&name, val) },
1313 None => unsafe { env::remove_var(&name) },
1314 }
1315 }
1316 }
1317 }
1318
1319 #[test]
1322 fn test_default_settings() {
1323 let settings = Settings::default();
1324 assert_eq!(settings.version, SETTINGS_VERSION);
1325 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1326 assert_eq!(settings.theme, "default");
1327 assert!(settings.last_used_model.is_none());
1328 assert!(settings.last_used_provider.is_none());
1329 assert!(settings.extensions_enabled);
1330 assert!(settings.auto_compaction);
1331 assert_eq!(settings.tool_timeout_seconds, 120);
1332 assert!(settings.stream_responses);
1333 }
1334
1335 #[test]
1336 fn test_merge_cli() {
1337 let mut settings = Settings::default();
1338 settings.last_used_model = Some("gpt-4o".to_string());
1339
1340 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1341 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1342
1343 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1344 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1345
1346 settings.merge_cli(
1348 None,
1349 None,
1350 Some(true),
1351 Some(false),
1352 Some(vec!["openai/gpt-4o".to_string()]),
1353 Some(false),
1354 );
1355 assert!(settings.enable_routing);
1356 assert!(!settings.prefer_cost_efficient);
1357 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1358 assert!(!settings.disable_fallback);
1359
1360 let mut settings2 = Settings::default();
1362 settings2.merge_cli(None, None, None, None, None, Some(true));
1363 assert!(settings2.disable_fallback);
1364 assert!(!settings2.enable_fallback);
1365 }
1366
1367 #[test]
1370 fn test_layer_file_overrides() {
1371 let base = Settings::default();
1372
1373 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1374 let toml_content = r#"
1375last_used_model = "openai/gpt-4o"
1376theme = "dracula"
1377"#;
1378 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1379
1380 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1381 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1382 assert_eq!(merged.theme, "dracula");
1383 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1385 assert!(merged.extensions_enabled);
1386 }
1387
1388 #[test]
1389 fn test_layer_file_preserves_unset() {
1390 let mut base = Settings::default();
1391 base.last_used_provider = Some("deepseek".to_string());
1392
1393 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1394 let toml_content = "theme = \"monokai\"\n";
1396 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1397
1398 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1399 assert_eq!(merged.theme, "monokai");
1400 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1401 }
1402
1403 #[test]
1404 fn test_load_from_dir_with_project_config() {
1405 let _guard = EnvGuard::new(&[
1406 "OXI_MODEL",
1407 "OXI_PROVIDER",
1408 "OXI_THEME",
1409 "OXI_TOOL_TIMEOUT",
1410 "OXI_TEMPERATURE",
1411 "OXI_MAX_TOKENS",
1412 "OXI_SESSION_DIR",
1413 "OXI_STREAM",
1414 "OXI_EXTENSIONS_ENABLED",
1415 ]);
1416 let tmp = tempfile::tempdir().unwrap();
1417 let oxi_dir = tmp.path().join(".oxi");
1418 fs::create_dir_all(&oxi_dir).unwrap();
1419 let settings_path = oxi_dir.join("settings.toml");
1420 fs::write(
1422 &settings_path,
1423 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1424 )
1425 .unwrap();
1426
1427 let settings = Settings::load_from(tmp.path()).unwrap();
1428 assert_eq!(
1430 settings.last_used_model,
1431 Some("gemini-2.0-flash".to_string())
1432 );
1433 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1434 }
1435
1436 #[test]
1437 fn test_load_from_dir_no_config() {
1438 let _guard = EnvGuard::new(&[
1440 "OXI_MODEL",
1441 "OXI_PROVIDER",
1442 "OXI_THEME",
1443 "OXI_TOOL_TIMEOUT",
1444 "OXI_TEMPERATURE",
1445 "OXI_MAX_TOKENS",
1446 "OXI_SESSION_DIR",
1447 "OXI_STREAM",
1448 "OXI_EXTENSIONS_ENABLED",
1449 ]);
1450 let tmp = tempfile::tempdir().unwrap();
1451 let global = tmp.path().join("nonexistent-settings.json");
1456 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1457 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1458 }
1459 #[test]
1460 fn test_from_env() {
1461 let _guard = EnvGuard::new(&[
1464 "OXI_MODEL",
1466 "OXI_THEME",
1467 "OXI_TOOL_TIMEOUT",
1468 "OXI_PROVIDER",
1469 "OXI_DEFAULT_MODEL",
1470 ]);
1471
1472 let settings = Settings::from_env();
1473 assert_eq!(settings.last_used_model, None);
1475 assert_eq!(settings.theme, "default");
1476 assert_eq!(settings.tool_timeout_seconds, 120);
1477 }
1478
1479 #[test]
1480 fn test_apply_env_boolish() {
1481 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1484 unsafe { env::set_var("OXI_STREAM", "false") };
1485 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1486
1487 let mut settings = Settings::default();
1488 settings.apply_env();
1489 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1493
1494 #[test]
1495 fn test_apply_env_temperature() {
1496 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1498 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1499
1500 let mut settings = Settings::default();
1501 settings.apply_env();
1502 assert_eq!(settings.default_temperature, None);
1504 }
1505
1506 #[test]
1507 fn test_env_does_not_override_when_unset() {
1508 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1509 let settings = Settings::from_env();
1510 assert!(settings.last_used_model.is_none());
1511 assert!(settings.last_used_provider.is_none());
1512 }
1513
1514 #[test]
1515 fn test_parse_thinking_level() {
1516 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1517 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1518 assert_eq!(
1519 parse_thinking_level("MINIMAL"),
1520 Some(ThinkingLevel::Minimal)
1521 );
1522 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1523 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1524 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1525 assert_eq!(
1526 parse_thinking_level("Standard"),
1527 Some(ThinkingLevel::Medium)
1528 );
1529 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1530 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1531 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1532 assert_eq!(parse_thinking_level("invalid"), None);
1533 }
1534
1535 #[test]
1536 fn test_parse_boolish() {
1537 assert!(parse_boolish("true").unwrap());
1538 assert!(parse_boolish("1").unwrap());
1539 assert!(parse_boolish("yes").unwrap());
1540 assert!(parse_boolish("ON").unwrap());
1541 assert!(!parse_boolish("false").unwrap());
1542 assert!(!parse_boolish("0").unwrap());
1543 assert!(!parse_boolish("no").unwrap());
1544 assert!(!parse_boolish("OFF").unwrap());
1545 assert!(parse_boolish("maybe").is_err());
1546 }
1547
1548 #[test]
1551 fn test_effective_model_returns_last_used() {
1552 let mut settings = Settings::default();
1553 settings.last_used_model = Some("openai/gpt-4o".to_string());
1554 assert_eq!(
1555 settings.effective_model(None),
1556 Some("openai/gpt-4o".to_string())
1557 );
1558 }
1559
1560 #[test]
1561 fn test_effective_model_cli_overrides() {
1562 let mut settings = Settings::default();
1563 settings.last_used_model = Some("openai/gpt-4o".to_string());
1564 assert_eq!(
1565 settings.effective_model(Some("anthropic/claude-3")),
1566 Some("anthropic/claude-3".to_string())
1567 );
1568 }
1569
1570 #[test]
1571 fn test_effective_model_none_when_unset() {
1572 let settings = Settings::default();
1573 assert_eq!(settings.effective_model(None), None);
1574 }
1575
1576 #[test]
1577 fn test_effective_model_falls_back_to_last_used() {
1578 let mut settings = Settings::default();
1579 settings.last_used_model = Some("anthropic/claude-3".to_string());
1580 assert_eq!(
1581 settings.effective_model(None),
1582 Some("anthropic/claude-3".to_string())
1583 );
1584 }
1585
1586 #[test]
1587 fn test_effective_model_returns_none_when_nothing_set() {
1588 let settings = Settings::default();
1589 assert_eq!(settings.effective_model(None), None);
1590 }
1591
1592 #[test]
1593 fn test_effective_temperature_prefers_f64() {
1594 let mut settings = Settings::default();
1595 settings.temperature = Some(0.5);
1596 settings.default_temperature = Some(0.7);
1597 assert_eq!(settings.effective_temperature(), Some(0.7));
1598 }
1599
1600 #[test]
1601 fn test_effective_temperature_falls_back_to_f32() {
1602 let mut settings = Settings::default();
1603 settings.temperature = Some(0.5);
1604 assert_eq!(settings.effective_temperature(), Some(0.5));
1605 }
1606
1607 #[test]
1608 fn test_effective_max_tokens_prefers_usize() {
1609 let mut settings = Settings::default();
1610 settings.max_tokens = Some(1024);
1611 settings.max_response_tokens = Some(4096);
1612 assert_eq!(settings.effective_max_tokens(), Some(4096));
1613 }
1614
1615 #[test]
1616 fn test_effective_max_tokens_falls_back_to_u32() {
1617 let mut settings = Settings::default();
1618 settings.max_tokens = Some(1024);
1619 assert_eq!(settings.effective_max_tokens(), Some(1024));
1620 }
1621
1622 #[test]
1625 fn test_effective_session_dir_default() {
1626 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1627 let settings = Settings::default();
1628 let dir = settings.effective_session_dir().unwrap();
1629 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1630 }
1631
1632 #[test]
1633 fn test_effective_session_dir_from_field() {
1634 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1635 let mut settings = Settings::default();
1636 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1637 assert_eq!(
1638 settings.effective_session_dir().unwrap(),
1639 PathBuf::from("/tmp/oxi-sessions")
1640 );
1641 }
1642
1643 #[test]
1644 fn test_effective_session_dir_env_disabled() {
1645 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1648 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1649 let settings = Settings::default();
1650 let dir = settings.effective_session_dir().unwrap();
1652 assert!(
1653 dir.ends_with("sessions"),
1654 "expected default sessions dir, got: {:?}",
1655 dir
1656 );
1657 }
1658
1659 #[test]
1662 fn test_migration_v0_to_v1() {
1663 let mut settings = Settings::default();
1664 settings.version = 0;
1665 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1668 assert_eq!(migrated.version, SETTINGS_VERSION);
1669 assert_eq!(migrated.tool_timeout_seconds, 120);
1670 }
1671
1672 #[test]
1673 fn test_migration_already_current() {
1674 let settings = Settings::default();
1675 let migrated = Settings::migrate(settings).unwrap();
1676 assert_eq!(migrated.version, SETTINGS_VERSION);
1677 }
1678
1679 #[test]
1680 fn test_migration_v3_to_v4_splits_model() {
1681 let mut settings = Settings::default();
1682 settings.version = 3;
1683 settings.default_model = Some("openai/gpt-4o".to_string());
1684 settings.default_provider = None;
1685
1686 let migrated = Settings::migrate(settings).unwrap();
1687 assert_eq!(migrated.version, SETTINGS_VERSION);
1688 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1689 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1690 }
1691
1692 #[test]
1693 fn test_migration_v3_no_slash_keeps_model() {
1694 let mut settings = Settings::default();
1695 settings.version = 3;
1696 settings.default_model = Some("bare-model-name".to_string());
1697
1698 let migrated = Settings::migrate(settings).unwrap();
1699 assert_eq!(migrated.version, SETTINGS_VERSION);
1700 assert_eq!(
1701 migrated.last_used_model,
1702 Some("bare-model-name".to_string())
1703 );
1704 }
1705
1706 #[test]
1707 fn test_migration_future_version_fails() {
1708 let mut settings = Settings::default();
1709 settings.version = 9999;
1710 assert!(Settings::migrate(settings).is_err());
1711 }
1712
1713 #[test]
1716 fn test_default_output_languages_is_empty() {
1717 let settings = Settings::default();
1718 assert!(
1719 settings.output_languages.is_empty(),
1720 "all channels should default to auto (empty map)"
1721 );
1722 }
1723
1724 #[test]
1725 fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1726 let mut settings = Settings::default();
1727 settings.version = 4;
1728 settings
1729 .output_languages
1730 .insert("response".to_string(), "ko".to_string());
1731 settings
1732 .output_languages
1733 .insert("commit_message".to_string(), "en".to_string());
1734
1735 let migrated = Settings::migrate(settings).unwrap();
1736 assert_eq!(migrated.version, SETTINGS_VERSION);
1737 assert_eq!(
1738 migrated.output_languages.get("response"),
1739 Some(&"ko".to_string())
1740 );
1741 assert_eq!(
1742 migrated.output_languages.get("commit_message"),
1743 Some(&"en".to_string())
1744 );
1745 }
1746
1747 #[test]
1748 fn test_migration_v4_to_v5_creates_empty_if_missing() {
1749 let mut settings = Settings::default();
1753 settings.version = 4;
1754 assert!(settings.output_languages.is_empty());
1755
1756 let migrated = Settings::migrate(settings).unwrap();
1757 assert_eq!(migrated.version, SETTINGS_VERSION);
1758 assert!(migrated.output_languages.is_empty());
1759 }
1760
1761 #[test]
1762 fn test_validate_keeps_user_defined_channel() {
1763 let mut settings = Settings::default();
1768 settings
1769 .output_languages
1770 .insert("pr_description".to_string(), "en".to_string()); settings
1772 .output_languages
1773 .insert("response".to_string(), "ko".to_string()); settings.validate_output_languages();
1776
1777 assert!(settings.output_languages.contains_key("pr_description"));
1778 assert!(settings.output_languages.contains_key("response"));
1779 assert_eq!(
1780 settings.output_languages.get("pr_description"),
1781 Some(&"en".to_string())
1782 );
1783 assert_eq!(
1784 settings.output_languages.get("response"),
1785 Some(&"ko".to_string())
1786 );
1787 }
1788
1789 #[test]
1790 fn test_validate_keeps_unknown_lang_with_warning() {
1791 let mut settings = Settings::default();
1792 settings
1793 .output_languages
1794 .insert("response".to_string(), "klingon".to_string()); settings
1796 .output_languages
1797 .insert("commit_message".to_string(), "en".to_string()); settings.validate_output_languages();
1800
1801 assert_eq!(
1804 settings.output_languages.get("response"),
1805 Some(&"klingon".to_string())
1806 );
1807 assert_eq!(
1808 settings.output_languages.get("commit_message"),
1809 Some(&"en".to_string())
1810 );
1811 }
1812
1813 #[test]
1814 fn test_known_channels_table_includes_core_four() {
1815 let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1816 assert!(keys.contains(&"response"));
1817 assert!(keys.contains(&"code_comment"));
1818 assert!(keys.contains(&"documentation"));
1819 assert!(keys.contains(&"commit_message"));
1820 }
1821
1822 #[test]
1823 fn test_known_langs_table_includes_auto_and_english() {
1824 let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1825 assert!(codes.contains(&"auto"));
1826 assert!(codes.contains(&"en"));
1827 }
1828
1829 #[test]
1830 fn test_default_language_policy_enabled_is_false() {
1831 let settings = Settings::default();
1833 assert!(
1834 !settings.language_policy_enabled,
1835 "language_policy_enabled must default to false (opt-in)"
1836 );
1837 }
1838
1839 #[test]
1840 fn test_migration_v5_to_v6_defaults_master_toggle_to_off() {
1841 let mut settings = Settings::default();
1845 settings.version = 5;
1846 settings
1847 .output_languages
1848 .insert("response".to_string(), "ko".to_string());
1849 settings
1850 .output_languages
1851 .insert("commit_message".to_string(), "en".to_string());
1852
1853 let migrated = Settings::migrate(settings).unwrap();
1854 assert_eq!(migrated.version, SETTINGS_VERSION);
1855 assert!(
1856 !migrated.language_policy_enabled,
1857 "v5 → v6 migration must default language_policy_enabled to false"
1858 );
1859 assert_eq!(
1861 migrated.output_languages.get("response"),
1862 Some(&"ko".to_string())
1863 );
1864 assert_eq!(
1865 migrated.output_languages.get("commit_message"),
1866 Some(&"en".to_string())
1867 );
1868 }
1869
1870 #[test]
1871 fn test_default_glyph_set_is_unicode() {
1872 let settings = Settings::default();
1873 assert_eq!(
1874 settings.glyph_set,
1875 GlyphSet::Unicode,
1876 "glyph_set must default to Unicode"
1877 );
1878 }
1879
1880 #[test]
1881 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1882 let mut settings = Settings::default();
1885 settings.version = 7;
1886 settings.glyph_set = GlyphSet::default();
1888
1889 let migrated = Settings::migrate(settings).unwrap();
1890 assert_eq!(migrated.version, SETTINGS_VERSION);
1891 assert_eq!(
1892 migrated.glyph_set,
1893 GlyphSet::Unicode,
1894 "v7 → v8 migration must default glyph_set to unicode"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_glyph_set_persists_through_roundtrip() {
1900 let mut original = Settings::default();
1904 original.glyph_set = GlyphSet::Nerd;
1905 let content = toml::to_string_pretty(&original).unwrap();
1906 assert!(
1907 content.contains("glyph_set = \"nerd\""),
1908 "nerd preset must serialize to snake_case; got:\n{content}"
1909 );
1910 let loaded: Settings = toml::from_str(&content).unwrap();
1911 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1912 original.glyph_set = GlyphSet::Unicode;
1914 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1915 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1916 }
1917
1918 #[test]
1919 fn test_save_and_load_roundtrip_preserves_language_policy_enabled() {
1920 let tmp = tempfile::tempdir().unwrap();
1921 let settings_path = tmp.path().join("settings.toml");
1922
1923 let mut original = Settings::default();
1924 original.language_policy_enabled = true;
1925 original
1926 .output_languages
1927 .insert("response".to_string(), "ko".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!(loaded.language_policy_enabled);
1936 assert_eq!(
1937 loaded.output_languages.get("response"),
1938 Some(&"ko".to_string())
1939 );
1940 }
1941
1942 #[test]
1943 fn test_save_and_load_roundtrip_preserves_output_languages() {
1944 let tmp = tempfile::tempdir().unwrap();
1945 let settings_path = tmp.path().join("settings.toml");
1946
1947 let mut original = Settings::default();
1948 original
1949 .output_languages
1950 .insert("response".to_string(), "ko".to_string());
1951 original
1952 .output_languages
1953 .insert("commit_message".to_string(), "en".to_string());
1954
1955 let content = toml::to_string_pretty(&original).unwrap();
1956 fs::write(&settings_path, &content).unwrap();
1957
1958 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1959 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1960
1961 assert_eq!(
1962 loaded.output_languages.get("response"),
1963 Some(&"ko".to_string())
1964 );
1965 assert_eq!(
1966 loaded.output_languages.get("commit_message"),
1967 Some(&"en".to_string())
1968 );
1969 }
1970
1971 #[test]
1974 fn test_save_and_load_roundtrip() {
1975 let tmp = tempfile::tempdir().unwrap();
1976 let settings_path = tmp.path().join("settings.toml");
1977
1978 let mut original = Settings::default();
1979 original.last_used_model = Some("gpt-4o".to_string());
1980 original.last_used_provider = Some("openai".to_string());
1981 original.theme = "dracula".to_string();
1982 original.tool_timeout_seconds = 60;
1983
1984 let content = toml::to_string_pretty(&original).unwrap();
1986 fs::write(&settings_path, &content).unwrap();
1987
1988 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1990 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1991
1992 assert_eq!(loaded.last_used_model, original.last_used_model);
1993 assert_eq!(loaded.theme, original.theme);
1994 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1995 }
1996
1997 #[test]
1998 fn test_toml_roundtrip_preserves_new_fields() {
1999 let mut settings = Settings::default();
2000 settings.default_temperature = Some(0.8);
2001 settings.max_response_tokens = Some(8192);
2002 settings.auto_compaction = false;
2003 settings.extensions_enabled = false;
2004 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
2005
2006 let toml_str = toml::to_string_pretty(&settings).unwrap();
2007 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2008
2009 assert_eq!(parsed.default_temperature, Some(0.8));
2010 assert_eq!(parsed.max_response_tokens, Some(8192));
2011 assert!(!parsed.auto_compaction);
2012 assert!(!parsed.extensions_enabled);
2013 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
2014 }
2015
2016 #[test]
2019 fn test_json_roundtrip() {
2020 let mut settings = Settings::default();
2021 settings.last_used_model = Some("gpt-4o".to_string());
2022 settings.last_used_provider = Some("openai".to_string());
2023 settings.theme = "dracula".to_string();
2024 settings.tool_timeout_seconds = 60;
2025 settings.default_temperature = Some(0.8);
2026 settings.max_response_tokens = Some(8192);
2027
2028 let json_str = serde_json::to_string_pretty(&settings).unwrap();
2029 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
2030
2031 assert_eq!(parsed.last_used_model, settings.last_used_model);
2032 assert_eq!(parsed.theme, settings.theme);
2033 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
2034 assert_eq!(parsed.default_temperature, settings.default_temperature);
2035 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
2036 }
2037
2038 #[test]
2039 fn test_json_serialize_for_format() {
2040 let mut settings = Settings::default();
2041 settings.last_used_model = Some("claude-3".to_string());
2042 settings.last_used_provider = Some("anthropic".to_string());
2043 settings.thinking_level = ThinkingLevel::Minimal;
2044
2045 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
2046 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
2047
2048 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
2049 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
2050 }
2051
2052 #[test]
2053 fn test_toml_serialize_for_format() {
2054 let mut settings = Settings::default();
2055 settings.last_used_model = Some("gemini-pro".to_string());
2056 settings.last_used_provider = Some("google".to_string());
2057 settings.thinking_level = ThinkingLevel::High;
2058
2059 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
2060 let parsed: Settings = toml::from_str(&toml_content).unwrap();
2061
2062 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2063 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
2064 }
2065
2066 #[test]
2067 fn test_parse_from_str_json() {
2068 let json_content = r#"{
2069 "last_used_model": "gpt-4",
2070 "last_used_provider": "openai",
2071 "theme": "nord",
2072 "tool_timeout_seconds": 90
2073 }"#;
2074
2075 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
2076 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
2077 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
2078 assert_eq!(settings.theme, "nord");
2079 assert_eq!(settings.tool_timeout_seconds, 90);
2080 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2082 assert!(settings.extensions_enabled);
2083 }
2084
2085 #[test]
2086 fn test_parse_from_str_toml() {
2087 let toml_content = r#"
2088last_used_model = "claude-opus"
2089last_used_provider = "anthropic"
2090theme = "monokai"
2091tool_timeout_seconds = 45
2092"#;
2093
2094 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
2095 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
2096 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
2097 assert_eq!(settings.theme, "monokai");
2098 assert_eq!(settings.tool_timeout_seconds, 45);
2099 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2100 }
2101
2102 #[test]
2103 fn test_layer_file_json() {
2104 let base = Settings::default();
2105
2106 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2107 let json_content = r#"{
2108 "last_used_model": "gpt-4o",
2109 "last_used_provider": "openai",
2110 "theme": "dracula",
2111 "auto_compaction": false
2112 }"#;
2113 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2114
2115 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2116 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
2117 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
2118 assert_eq!(merged.theme, "dracula");
2119 assert!(!merged.auto_compaction);
2120 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
2122 assert!(merged.extensions_enabled);
2123 assert_eq!(merged.tool_timeout_seconds, 120);
2124 }
2125
2126 #[test]
2127 fn test_layer_file_json_preserves_unset() {
2128 let mut base = Settings::default();
2129 base.last_used_provider = Some("deepseek".to_string());
2130
2131 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2132 let json_content = r#"{ "theme": "nord" }"#;
2133 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2134
2135 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2136 assert_eq!(merged.theme, "nord");
2137 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
2138 }
2139
2140 #[test]
2141 fn test_save_to_json() {
2142 let tmp = tempfile::tempdir().unwrap();
2143 let settings_path = tmp.path().join("settings.json");
2144
2145 let mut settings = Settings::default();
2146 settings.last_used_model = Some("gpt-4o".to_string());
2147 settings.last_used_provider = Some("openai".to_string());
2148 settings.theme = "dracula".to_string();
2149 settings.tool_timeout_seconds = 60;
2150
2151 settings.save_to(&settings_path).unwrap();
2152
2153 let content = fs::read_to_string(&settings_path).unwrap();
2155 let parsed: Settings = serde_json::from_str(&content).unwrap();
2156 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
2157 assert_eq!(parsed.theme, "dracula");
2158 assert_eq!(parsed.tool_timeout_seconds, 60);
2159 }
2160
2161 #[test]
2162 fn test_save_to_toml() {
2163 let tmp = tempfile::tempdir().unwrap();
2164 let settings_path = tmp.path().join("settings.toml");
2165
2166 let mut settings = Settings::default();
2167 settings.last_used_model = Some("gemini-pro".to_string());
2168 settings.last_used_provider = Some("google".to_string());
2169 settings.theme = "monokai".to_string();
2170 settings.tool_timeout_seconds = 90;
2171
2172 settings.save_to(&settings_path).unwrap();
2173
2174 let content = fs::read_to_string(&settings_path).unwrap();
2176 let parsed: Settings = toml::from_str(&content).unwrap();
2177 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2178 assert_eq!(parsed.theme, "monokai");
2179 assert_eq!(parsed.tool_timeout_seconds, 90);
2180 }
2181
2182 #[test]
2183 fn test_load_from_dir_with_json_project_config() {
2184 let _guard = EnvGuard::new(&[
2185 "OXI_MODEL",
2186 "OXI_PROVIDER",
2187 "OXI_THEME",
2188 "OXI_TOOL_TIMEOUT",
2189 "OXI_TEMPERATURE",
2190 "OXI_MAX_TOKENS",
2191 "OXI_SESSION_DIR",
2192 "OXI_STREAM",
2193 "OXI_EXTENSIONS_ENABLED",
2194 ]);
2195 let tmp = tempfile::tempdir().unwrap();
2196 let oxi_dir = tmp.path().join(".oxi");
2197 fs::create_dir_all(&oxi_dir).unwrap();
2198 let settings_path = oxi_dir.join("settings.json");
2199 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
2201 fs::write(&settings_path, json_content).unwrap();
2202
2203 let settings = Settings::load_from(tmp.path()).unwrap();
2204 assert_eq!(
2206 settings.last_used_model,
2207 Some("gemini-2.0-flash".to_string())
2208 );
2209 assert_eq!(settings.last_used_provider, Some("google".to_string()));
2210 }
2211
2212 #[test]
2213 fn test_find_project_settings_json_priority() {
2214 let tmp = tempfile::tempdir().unwrap();
2215 let oxi_dir = tmp.path().join(".oxi");
2216 fs::create_dir_all(&oxi_dir).unwrap();
2217
2218 let json_path = oxi_dir.join("settings.json");
2220 let toml_path = oxi_dir.join("settings.toml");
2221 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2222 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2223
2224 let found = Settings::find_project_settings(tmp.path());
2226 assert!(found.is_some());
2227 assert_eq!(
2228 found.unwrap().file_name().unwrap().to_str().unwrap(),
2229 "settings.json"
2230 );
2231 }
2232
2233 #[test]
2234 fn test_find_project_settings_json_only() {
2235 let tmp = tempfile::tempdir().unwrap();
2236 let oxi_dir = tmp.path().join(".oxi");
2237 fs::create_dir_all(&oxi_dir).unwrap();
2238
2239 let json_path = oxi_dir.join("settings.json");
2240 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2241
2242 let found = Settings::find_project_settings(tmp.path());
2243 assert!(found.is_some());
2244 assert_eq!(
2245 found.unwrap().file_name().unwrap().to_str().unwrap(),
2246 "settings.json"
2247 );
2248 }
2249
2250 #[test]
2251 fn test_find_project_settings_toml_fallback() {
2252 let tmp = tempfile::tempdir().unwrap();
2253 let oxi_dir = tmp.path().join(".oxi");
2254 fs::create_dir_all(&oxi_dir).unwrap();
2255
2256 let toml_path = oxi_dir.join("settings.toml");
2257 fs::write(&toml_path, r#"theme = "test""#).unwrap();
2258
2259 let found = Settings::find_project_settings(tmp.path());
2260 assert!(found.is_some());
2261 assert_eq!(
2262 found.unwrap().file_name().unwrap().to_str().unwrap(),
2263 "settings.toml"
2264 );
2265 }
2266
2267 #[test]
2268 fn test_detect_format() {
2269 let json_path = PathBuf::from("/test/settings.json");
2270 let toml_path = PathBuf::from("/test/settings.toml");
2271 let unknown_path = PathBuf::from("/test/settings");
2272
2273 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2274 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2275 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2276 }
2278
2279 #[test]
2280 fn test_settings_format_extension() {
2281 assert_eq!(SettingsFormat::Json.extension(), "json");
2282 assert_eq!(SettingsFormat::Toml.extension(), "toml");
2283 }
2284
2285 #[test]
2286 fn test_layer_json_over_toml() {
2287 let tmp = tempfile::tempdir().unwrap();
2289 let oxi_dir = tmp.path().join(".oxi");
2290 fs::create_dir_all(&oxi_dir).unwrap();
2291
2292 let json_path = oxi_dir.join("settings.json");
2293 let toml_path = oxi_dir.join("settings.toml");
2294
2295 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2297 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2299
2300 let settings = Settings::load_from(tmp.path()).unwrap();
2302 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2303 }
2304
2305 #[test]
2306 fn test_mixed_format_loading() {
2307 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2309 let toml_content = r#"
2310last_used_model = "loaded-via-toml"
2311theme = "loaded-theme"
2312stream_responses = false
2313"#;
2314 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2315
2316 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2317 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2318 assert_eq!(merged.theme, "loaded-theme");
2319 assert!(!merged.stream_responses);
2320 }
2321
2322 #[test]
2323 fn test_merge_json_values() {
2324 let base = serde_json::json!({
2325 "version": 1,
2326 "theme": "default",
2327 "extensions": ["ext1"],
2328 "nested": {
2329 "a": 1,
2330 "b": 2
2331 }
2332 });
2333
2334 let override_ = serde_json::json!({
2335 "version": 2,
2336 "theme": "dark",
2337 "extensions": ["ext2"],
2338 "nested": {
2339 "b": 20,
2340 "c": 30
2341 }
2342 });
2343
2344 let merged = merge_json_values(base, override_);
2345
2346 assert_eq!(merged["version"], 2);
2347 assert_eq!(merged["theme"], "dark");
2348 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2350 assert_eq!(merged["nested"]["a"], 1);
2352 assert_eq!(merged["nested"]["b"], 20);
2353 assert_eq!(merged["nested"]["c"], 30);
2354 }
2355
2356 #[test]
2357 fn test_save_project_preserves_existing_format() {
2358 let tmp = tempfile::tempdir().unwrap();
2359 let oxi_dir = tmp.path().join(".oxi");
2360 fs::create_dir_all(&oxi_dir).unwrap();
2361
2362 let toml_path = oxi_dir.join("settings.toml");
2364 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2365
2366 let mut settings = Settings::default();
2367 settings.theme = "new-theme".to_string();
2368 settings.save_project(tmp.path()).unwrap();
2369
2370 let content = fs::read_to_string(&toml_path).unwrap();
2372 assert!(content.contains("new-theme"));
2373 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2374 }
2375
2376 #[test]
2377 fn test_save_project_creates_json_by_default() {
2378 let tmp = tempfile::tempdir().unwrap();
2379 let oxi_dir = tmp.path().join(".oxi");
2380 fs::create_dir_all(&oxi_dir).unwrap();
2381 let mut settings = Settings::default();
2384 settings.theme = "json-theme".to_string();
2385 settings.save_project(tmp.path()).unwrap();
2386
2387 let json_path = oxi_dir.join("settings.json");
2389 assert!(json_path.exists());
2390 let content = fs::read_to_string(&json_path).unwrap();
2391 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2392 assert!(content.contains("json-theme"));
2393 }
2394
2395 #[test]
2398 fn test_custom_provider_default_api() {
2399 use super::CustomProvider;
2400 let cp = CustomProvider {
2401 name: "test".to_string(),
2402 base_url: "https://api.test.com/v1".to_string(),
2403 api_key_env: "TEST_API_KEY".to_string(),
2404 api: super::default_custom_provider_api(),
2405 };
2406 assert_eq!(cp.api, "openai-completions");
2407 }
2408
2409 #[test]
2410 fn test_custom_provider_toml_deserialize() {
2411 let toml_content = r#"
2412[[custom_providers]]
2413name = "minimax"
2414base_url = "https://api.minimax.chat/v1"
2415api_key_env = "MINIMAX_API_KEY"
2416api = "openai-completions"
2417
2418[[custom_providers]]
2419name = "zai"
2420base_url = "https://api.z.ai/v1"
2421api_key_env = "ZAI_API_KEY"
2422api = "openai-responses"
2423"#;
2424 let settings: Settings = toml::from_str(toml_content).unwrap();
2425 assert_eq!(settings.custom_providers.len(), 2);
2426 assert_eq!(settings.custom_providers[0].name, "minimax");
2427 assert_eq!(
2428 settings.custom_providers[0].base_url,
2429 "https://api.minimax.chat/v1"
2430 );
2431 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2432 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2433 assert_eq!(settings.custom_providers[1].name, "zai");
2434 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2435 }
2436
2437 #[test]
2438 fn test_custom_provider_json_deserialize() {
2439 let json_content = r#"{
2440 "custom_providers": [
2441 {
2442 "name": "minimax",
2443 "base_url": "https://api.minimax.chat/v1",
2444 "api_key_env": "MINIMAX_API_KEY",
2445 "api": "openai-completions"
2446 }
2447 ]
2448 }"#;
2449 let settings: Settings = serde_json::from_str(json_content).unwrap();
2450 assert_eq!(settings.custom_providers.len(), 1);
2451 assert_eq!(settings.custom_providers[0].name, "minimax");
2452 }
2453
2454 #[test]
2455 fn test_custom_provider_toml_roundtrip() {
2456 let mut settings = Settings::default();
2457 settings.custom_providers.push(super::CustomProvider {
2458 name: "test".to_string(),
2459 base_url: "https://api.test.com/v1".to_string(),
2460 api_key_env: "TEST_API_KEY".to_string(),
2461 api: "openai-completions".to_string(),
2462 });
2463
2464 let toml_str = toml::to_string_pretty(&settings).unwrap();
2465 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2466 assert_eq!(parsed.custom_providers.len(), 1);
2467 assert_eq!(parsed.custom_providers[0].name, "test");
2468 assert_eq!(
2469 parsed.custom_providers[0].base_url,
2470 "https://api.test.com/v1"
2471 );
2472 }
2473
2474 #[test]
2475 fn test_custom_provider_defaults_empty() {
2476 let settings = Settings::default();
2477 assert!(settings.custom_providers.is_empty());
2478 }
2479
2480 #[test]
2481 fn test_custom_provider_layer_file() {
2482 let base = Settings::default();
2483
2484 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2485 let toml_content = r#"
2486[[custom_providers]]
2487name = "my-provider"
2488base_url = "https://api.my-provider.com/v1"
2489api_key_env = "MY_PROVIDER_API_KEY"
2490"#;
2491 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2492
2493 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2494 assert_eq!(merged.custom_providers.len(), 1);
2495 assert_eq!(merged.custom_providers[0].name, "my-provider");
2496 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2498 }
2499}