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;
41
42pub const KNOWN_CHANNELS: &[(&str, &str)] = &[
53 ("response", "Your conversational responses to the user"),
54 (
55 "code_comment",
56 "Code comments you write (//, /* */, #, etc.)",
57 ),
58 (
59 "documentation",
60 "Documentation (markdown files, README, AGENTS.md, doc comments)",
61 ),
62 ("commit_message", "Git commit messages (subject + body)"),
63];
64
65pub const KNOWN_LANGS: &[(&str, &str)] = &[
76 ("auto", "Auto (match user)"),
77 ("en", "English"),
78 ("ko", "Korean (한국어)"),
79 ("ja", "Japanese (日本語)"),
80 ("zh", "Chinese (中文)"),
81 ("es", "Spanish"),
82 ("fr", "French"),
83 ("de", "German"),
84];
85
86#[allow(dead_code)]
89const ENV_PREFIX: &str = "OXI_";
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
93#[serde(rename_all = "snake_case")]
94pub enum ThinkingLevel {
95 #[default]
97 Off,
98 Minimal,
100 Low,
102 Medium,
104 High,
106 XHigh,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
116#[serde(rename_all = "snake_case")]
117pub enum EditFormat {
118 #[default]
120 Hashline,
121 StrReplace,
123}
124#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CustomProvider {
130 pub name: String,
132 pub base_url: String,
134 pub api_key_env: String,
136 #[serde(default = "default_custom_provider_api")]
138 pub api: String,
139}
140
141fn default_custom_provider_api() -> String {
142 "openai-completions".to_string()
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct Settings {
148 #[serde(default)]
151 pub version: u32,
152
153 #[serde(default = "default_thinking_level")]
156 pub thinking_level: ThinkingLevel,
157 #[serde(default = "default_theme")]
159 pub theme: String,
160
161 #[serde(default)]
168 pub glyph_set: GlyphSet,
169
170 #[serde(default, skip_serializing)]
172 pub default_model: Option<String>,
173
174 #[serde(default, skip_serializing)]
176 pub default_provider: Option<String>,
177
178 #[serde(default)]
181 pub last_used_model: Option<String>,
182
183 #[serde(default)]
185 pub last_used_provider: Option<String>,
186
187 pub max_tokens: Option<u32>,
189
190 pub temperature: Option<f32>,
192
193 pub default_temperature: Option<f64>,
195
196 pub max_response_tokens: Option<usize>,
198
199 #[serde(default = "default_session_history_size")]
202 pub session_history_size: usize,
203
204 pub session_dir: Option<PathBuf>,
206
207 #[serde(default = "default_true")]
210 pub stream_responses: bool,
211
212 #[serde(default = "default_true")]
214 pub extensions_enabled: bool,
215
216 #[serde(default = "default_true")]
218 pub auto_compaction: bool,
219
220 #[serde(default)]
223 pub disabled_tools: Vec<String>,
224
225 #[serde(default = "default_tool_timeout")]
228 pub tool_timeout_seconds: u64,
229
230 #[serde(default, alias = "questionnaire_timeout_secs")]
233 pub ask_timeout_secs: u64,
234
235 #[serde(default)]
238 pub extensions: Vec<String>,
239
240 #[serde(default)]
242 pub skills: Vec<String>,
243
244 #[serde(default)]
246 pub prompts: Vec<String>,
247
248 #[serde(default)]
250 pub themes: Vec<String>,
251
252 #[serde(default)]
255 pub custom_providers: Vec<CustomProvider>,
256
257 #[serde(default)]
262 pub dynamic_models: HashMap<String, Vec<String>>,
263
264 #[serde(default = "default_false")]
267 pub enable_routing: bool,
268
269 #[serde(default)]
271 pub router_profile: Option<String>,
272
273 #[serde(default = "default_true")]
275 pub prefer_cost_efficient: bool,
276
277 #[serde(default)]
279 pub fallback_chain: Vec<String>,
280
281 #[serde(default = "default_true")]
283 pub enable_fallback: bool,
284
285 #[serde(default)]
287 pub disable_fallback: bool,
288
289 #[serde(default = "default_circuit_failure_threshold")]
291 pub circuit_breaker_failure_threshold: u32,
292
293 #[serde(default = "default_circuit_open_duration_secs")]
295 pub circuit_breaker_open_duration_secs: u64,
296
297 #[serde(default)]
302 pub keybindings: HashMap<String, Vec<String>>,
303
304 #[serde(default)]
350 pub output_languages: HashMap<String, String>,
351
352 #[serde(default = "default_false")]
371 pub language_policy_enabled: bool,
372
373 #[serde(default)]
378 pub edit_format: EditFormat,
379
380 #[serde(default = "default_false")]
384 pub memory_enabled: bool,
385
386 #[serde(default)]
389 pub memory_db_path: Option<PathBuf>,
390
391 #[serde(default = "default_false")]
395 pub mnemopi_engine: bool,
396
397 #[serde(default)]
402 pub memory_backend: Option<String>,
403
404 #[serde(default = "default_false")]
410 pub memory_llm_extract: bool,
411
412 #[serde(default)]
416 pub memory_llm_extract_model: String,
417
418 #[serde(default = "default_embedding_provider")]
422 pub embedding_provider: String,
423
424 #[serde(default)]
426 pub embedding_base_url: Option<String>,
427
428 #[serde(default = "default_embedding_api_key_env")]
431 pub embedding_api_key_env: String,
432
433 #[serde(default = "default_embedding_model")]
436 pub embedding_model: String,
437
438 #[serde(default = "default_false")]
442 pub ttsr_enabled: bool,
443
444 #[serde(default = "default_ttsr_mode")]
446 pub ttsr_interrupt_mode: String,
447
448 #[serde(default)]
459 pub model_roles: HashMap<String, String>,
460 #[serde(default)]
464 pub advisor: AdvisorSettings,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct AdvisorSettings {
475 #[serde(default = "default_false")]
477 pub enabled: bool,
478 #[serde(default = "default_advisor_sync_backlog")]
482 pub sync_backlog: String,
483 #[serde(default)]
487 pub immune_turns: u64,
488}
489
490impl Default for AdvisorSettings {
491 fn default() -> Self {
492 Self {
493 enabled: false,
494 sync_backlog: default_advisor_sync_backlog(),
495 immune_turns: 0,
496 }
497 }
498}
499
500fn default_advisor_sync_backlog() -> String {
501 "off".to_string()
502}
503
504fn default_theme() -> String {
505 "default".to_string()
506}
507
508fn default_thinking_level() -> ThinkingLevel {
509 ThinkingLevel::Medium
510}
511
512fn default_session_history_size() -> usize {
513 100
514}
515
516fn default_true() -> bool {
517 true
518}
519
520fn default_false() -> bool {
521 false
522}
523
524fn default_ttsr_mode() -> String {
525 "prose_only".to_string()
526}
527
528fn default_circuit_failure_threshold() -> u32 {
529 5
530}
531
532fn default_circuit_open_duration_secs() -> u64 {
533 30
534}
535
536fn default_tool_timeout() -> u64 {
537 120
538}
539
540fn default_memory_backend() -> Option<String> {
541 None
542}
543
544fn default_embedding_provider() -> String {
545 "none".to_string()
546}
547
548fn default_embedding_model() -> String {
549 String::new()
550}
551
552fn default_embedding_api_key_env() -> String {
553 "OPENAI_API_KEY".to_string()
554}
555
556impl Default for Settings {
557 fn default() -> Self {
558 Self {
559 version: SETTINGS_VERSION,
560 thinking_level: ThinkingLevel::Medium,
561 theme: default_theme(),
562 glyph_set: GlyphSet::default(),
563 last_used_model: None,
564 last_used_provider: None,
565 default_model: None,
566 default_provider: None,
567 max_tokens: None,
568 temperature: None,
569 default_temperature: None,
570 max_response_tokens: None,
571 session_history_size: default_session_history_size(),
572 session_dir: None,
573 stream_responses: true,
574 extensions_enabled: true,
575 auto_compaction: true,
576 disabled_tools: Vec::new(),
577 tool_timeout_seconds: default_tool_timeout(),
578 ask_timeout_secs: 0,
579 extensions: Vec::new(),
580 skills: Vec::new(),
581 prompts: Vec::new(),
582 themes: Vec::new(),
583 custom_providers: Vec::new(),
584 dynamic_models: HashMap::new(),
585 enable_routing: false,
587 router_profile: None,
588 prefer_cost_efficient: true,
589 fallback_chain: Vec::new(),
590 enable_fallback: true,
591 disable_fallback: false,
592 circuit_breaker_failure_threshold: 5,
593 circuit_breaker_open_duration_secs: 30,
594 keybindings: HashMap::new(),
595 output_languages: HashMap::new(),
596 language_policy_enabled: false,
597 edit_format: EditFormat::default(),
598 memory_enabled: false,
599 memory_db_path: None,
600 mnemopi_engine: false,
601 memory_backend: default_memory_backend(),
602 memory_llm_extract: false,
603 memory_llm_extract_model: String::new(),
604 embedding_provider: default_embedding_provider(),
605 embedding_base_url: None,
606 embedding_api_key_env: default_embedding_api_key_env(),
607 embedding_model: default_embedding_model(),
608 ttsr_enabled: false,
609 ttsr_interrupt_mode: default_ttsr_mode(),
610 model_roles: HashMap::new(),
611 advisor: AdvisorSettings::default(),
612 }
613 }
614}
615
616impl Settings {
617 pub fn settings_dir() -> Result<PathBuf> {
621 let base = dirs::home_dir().context("Cannot determine home directory")?;
622 Ok(base.join(".oxi"))
623 }
624
625 pub fn settings_toml_path() -> Result<PathBuf> {
627 Ok(Self::settings_dir()?.join("settings.toml"))
628 }
629
630 pub fn settings_json_path() -> Result<PathBuf> {
632 Ok(Self::settings_dir()?.join("settings.json"))
633 }
634
635 pub fn settings_path() -> Result<PathBuf> {
642 let json_path = Self::settings_json_path()?;
643 let toml_path = Self::settings_toml_path()?;
644
645 if json_path.exists() && toml_path.exists() {
646 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
648 return Ok(json_path);
649 }
650
651 if json_path.exists() {
652 return Ok(json_path);
653 }
654
655 if toml_path.exists() {
656 return Ok(toml_path);
657 }
658
659 Ok(json_path)
661 }
662
663 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
668 let json_path = Self::settings_json_path()?;
669 let toml_path = Self::settings_toml_path()?;
670
671 let (primary, secondary) = if prefer_json {
672 (&json_path, &toml_path)
673 } else {
674 (&toml_path, &json_path)
675 };
676
677 if primary.exists() {
678 return Ok(primary.clone());
679 }
680
681 if secondary.exists() {
682 return Ok(secondary.clone());
683 }
684
685 Ok(primary.clone())
687 }
688
689 pub fn detect_format(path: &Path) -> SettingsFormat {
691 match path.extension().and_then(|e| e.to_str()) {
692 Some("json") => SettingsFormat::Json,
693 Some("toml") => SettingsFormat::Toml,
694 _ => SettingsFormat::Json, }
696 }
697
698 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
703 let mut dir = start_dir.to_path_buf();
704 loop {
705 let json_candidate = dir.join(".oxi").join("settings.json");
707 if json_candidate.exists() {
708 return Some(json_candidate);
709 }
710
711 let toml_candidate = dir.join(".oxi").join("settings.toml");
712 if toml_candidate.exists() {
713 return Some(toml_candidate);
714 }
715
716 if !dir.pop() {
717 return None;
718 }
719 }
720 }
721
722 pub fn effective_session_dir(&self) -> Result<PathBuf> {
726 if let Some(ref dir) = self.session_dir {
727 return Ok(dir.clone());
728 }
729 Ok(Self::settings_dir()?.join("sessions"))
730 }
731
732 pub fn load() -> Result<Self> {
750 Self::load_from_cwd()
751 }
752
753 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
759 Self::load_from_with(dir, None)
760 }
761
762 pub fn load_from_with(
781 dir: &std::path::Path,
782 global_override: Option<&std::path::Path>,
783 ) -> Result<Self> {
784 let mut settings = Settings::default();
786
787 let resolved_global: Option<std::path::PathBuf> = match global_override {
790 Some(p) => Some(p.to_path_buf()),
791 None => Self::settings_path().ok(),
792 };
793 if let Some(ref gp) = resolved_global
794 && gp.exists()
795 {
796 settings = Self::layer_file(&settings, gp)?;
797 }
798
799 if let Some(project_path) = Self::find_project_settings(dir) {
801 settings = Self::layer_file(&settings, &project_path)?;
802 }
803
804 settings.apply_env();
806
807 settings = Self::migrate(settings)?;
809
810 settings.validate_output_languages();
812
813 Ok(settings)
814 }
815
816 fn validate_output_languages(&mut self) {
823 if self.output_languages.is_empty() {
824 return;
825 }
826 let known_langs: std::collections::HashSet<&str> =
827 KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
828
829 for (channel, lang) in &self.output_languages {
830 if !known_langs.contains(lang.as_str()) {
831 tracing::warn!(
832 "Unknown output_languages language code '{}' for channel '{}'. \
833 Keeping as-is (the model will likely understand).",
834 lang,
835 channel
836 );
837 }
838 }
839 }
840
841 pub fn load_from_cwd() -> Result<Self> {
843 let cwd = env::current_dir().context("Cannot determine current directory")?;
844 Self::load_from(&cwd)
845 }
846
847 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
853 let content = fs::read_to_string(path)
854 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
855
856 let format = Self::detect_format(path);
857 let overlay: serde_json::Value = match format {
858 SettingsFormat::Toml => {
859 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
860 format!("Failed to parse TOML settings from {}", path.display())
861 })?;
862 toml_value_to_json(toml_value)
864 }
865 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
866 format!("Failed to parse JSON settings from {}", path.display())
867 })?,
868 };
869
870 let base_json =
874 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
875
876 let merged = merge_json_values(base_json, overlay);
877 let result: Settings =
878 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
879
880 Ok(result)
881 }
882
883 #[allow(dead_code)]
909 pub fn apply_env(&mut self) {
910 }
914
915 #[allow(dead_code)]
921 pub fn from_env() -> Self {
922 Self::default()
923 }
924
925 pub fn save(&self) -> Result<()> {
932 let dir = Self::settings_dir()?;
933 let path = Self::settings_path()?;
934
935 if !dir.exists() {
936 fs::create_dir_all(&dir).with_context(|| {
937 format!("Failed to create settings directory {}", dir.display())
938 })?;
939 }
940
941 let format = Self::detect_format(&path);
942 let content = Self::serialize_for_format(self, format)?;
943
944 let tmp_path = path.with_extension("tmp");
946 fs::write(&tmp_path, &content)
947 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
948 fs::rename(&tmp_path, &path)
949 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
950
951 Ok(())
952 }
953
954 pub fn save_to(&self, path: &Path) -> Result<()> {
956 if let Some(parent) = path.parent()
957 && !parent.exists()
958 {
959 fs::create_dir_all(parent)
960 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
961 }
962
963 let format = Self::detect_format(path);
964 let content = Self::serialize_for_format(self, format)?;
965
966 let tmp_path = path.with_extension("tmp");
968 fs::write(&tmp_path, &content)
969 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
970 fs::rename(&tmp_path, path)
971 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
972
973 Ok(())
974 }
975
976 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
980 let dir = project_dir.join(".oxi");
981
982 if !dir.exists() {
983 fs::create_dir_all(&dir).with_context(|| {
984 format!(
985 "Failed to create project settings directory {}",
986 dir.display()
987 )
988 })?;
989 }
990
991 let json_path = dir.join("settings.json");
993 let toml_path = dir.join("settings.toml");
994
995 let path = if json_path.exists() {
996 &json_path
997 } else if toml_path.exists() {
998 &toml_path
999 } else {
1000 &json_path
1002 };
1003
1004 let format = Self::detect_format(path);
1005 let content = Self::serialize_for_format(self, format)?;
1006
1007 let tmp_path = path.with_extension("tmp");
1009 fs::write(&tmp_path, &content)
1010 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
1011 fs::rename(&tmp_path, path)
1012 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
1013
1014 Ok(())
1015 }
1016
1017 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
1019 match format {
1020 SettingsFormat::Toml => {
1021 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
1022 }
1023 SettingsFormat::Json => serde_json::to_string_pretty(settings)
1024 .context("Failed to serialize settings to JSON"),
1025 }
1026 }
1027
1028 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
1030 match format {
1031 SettingsFormat::Toml => {
1032 toml::from_str(content).context("Failed to parse TOML settings")
1033 }
1034 SettingsFormat::Json => {
1035 serde_json::from_str(content).context("Failed to parse JSON settings")
1036 }
1037 }
1038 }
1039
1040 pub fn merge_cli(
1053 &mut self,
1054 model: Option<String>,
1055 provider: Option<String>,
1056 enable_routing: Option<bool>,
1057 prefer_cost_efficient: Option<bool>,
1058 fallback_chain: Option<Vec<String>>,
1059 disable_fallback: Option<bool>,
1060 ) {
1061 if let Some(m) = model {
1062 self.last_used_model = Some(m);
1063 }
1064 if let Some(p) = provider {
1065 self.last_used_provider = Some(p);
1066 }
1067 if let Some(r) = enable_routing {
1068 self.enable_routing = r;
1069 }
1070 if let Some(p) = prefer_cost_efficient {
1071 self.prefer_cost_efficient = p;
1072 }
1073 if let Some(fc) = fallback_chain
1074 && !fc.is_empty()
1075 {
1076 self.fallback_chain = fc;
1077 }
1078 if let Some(df) = disable_fallback {
1079 self.disable_fallback = df;
1080 if df {
1082 self.enable_fallback = false;
1083 }
1084 }
1085 }
1086
1087 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
1090 cli_model.map(String::from).or_else(|| {
1091 let model = self.last_used_model.as_ref()?;
1096 if model.contains('/') {
1097 Some(model.clone())
1099 } else if let Some(ref provider) = self.last_used_provider {
1100 Some(format!("{}/{}", provider, model))
1102 } else {
1103 Some(model.clone())
1104 }
1105 })
1106 }
1107
1108 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
1111 cli_provider
1112 .map(String::from)
1113 .or_else(|| self.last_used_provider.clone())
1114 }
1115
1116 pub fn effective_temperature(&self) -> Option<f64> {
1119 self.default_temperature
1120 .or(self.temperature.map(|t| t as f64))
1121 }
1122
1123 pub fn effective_max_tokens(&self) -> Option<usize> {
1126 self.max_response_tokens
1127 .or(self.max_tokens.map(|t| t as usize))
1128 }
1129
1130 pub fn router_profile(&self) -> Option<&str> {
1132 self.router_profile.as_deref()
1133 }
1134
1135 pub fn save_last_used(model_id: &str) {
1141 if let Ok(mut settings) = Self::load() {
1142 if let Some((provider, model)) = model_id.split_once('/') {
1143 settings.last_used_provider = Some(provider.to_string());
1144 settings.last_used_model = Some(model.to_string());
1145 } else {
1146 settings.last_used_model = Some(model_id.to_string());
1147 }
1148 let _ = settings.save();
1149 }
1150 }
1151
1152 pub fn save_theme(&mut self, name: &str) -> Result<()> {
1154 self.theme = name.to_string();
1155 self.save()
1156 }
1157
1158 pub fn get_theme_name(&self) -> String {
1160 if self.theme.is_empty() || self.theme == "default" {
1161 "oxi_dark".to_string()
1162 } else {
1163 self.theme.clone()
1164 }
1165 }
1166
1167 fn migrate(settings: Settings) -> Result<Settings> {
1183 let mut settings = settings;
1184
1185 match settings.version {
1186 SETTINGS_VERSION => {
1187 }
1189 0 => {
1190 if settings.tool_timeout_seconds == 0 {
1193 settings.tool_timeout_seconds = default_tool_timeout();
1194 }
1195 settings.version = SETTINGS_VERSION;
1196
1197 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1198 }
1199 1 | 2 => {
1200 settings.version = SETTINGS_VERSION;
1205 tracing::info!(
1206 "Migrated settings from version {} to {} (dynamic_models + output_languages + language_policy_enabled defaults applied)",
1207 settings.version,
1208 SETTINGS_VERSION
1209 );
1210 }
1211 3 => {
1212 if let Some(model) = settings.default_model.take() {
1214 if let Some((provider, model_name)) = model.split_once('/') {
1215 settings.last_used_provider = Some(provider.to_string());
1216 settings.last_used_model = Some(model_name.to_string());
1217 } else {
1218 settings.last_used_model = Some(model);
1219 }
1220 }
1221 settings.version = SETTINGS_VERSION;
1223 tracing::info!(
1224 "Migrated settings from version 3 to {} (default_model → last_used_model; output_languages + language_policy_enabled defaults)",
1225 SETTINGS_VERSION
1226 );
1227 }
1228 4 => {
1229 settings.version = SETTINGS_VERSION;
1233 tracing::info!(
1234 "Migrated settings from version 4 to {} (added output_languages + language_policy_enabled, both defaulted to off)",
1235 SETTINGS_VERSION
1236 );
1237 }
1238 5 => {
1239 settings.version = SETTINGS_VERSION;
1245 tracing::info!(
1246 "Migrated settings from version 5 to {} (added language_policy_enabled, defaulting to OFF — toggle ON in /settings to activate existing channels)",
1247 SETTINGS_VERSION
1248 );
1249 }
1250 6 => {
1251 settings.version = SETTINGS_VERSION;
1254 tracing::info!(
1255 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1256 SETTINGS_VERSION
1257 );
1258 }
1259 7 => {
1260 settings.version = SETTINGS_VERSION;
1263 tracing::info!(
1264 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1265 SETTINGS_VERSION
1266 );
1267 }
1268 8 => {
1269 settings.version = SETTINGS_VERSION;
1272 tracing::info!(
1273 "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1274 SETTINGS_VERSION
1275 );
1276 }
1277 v if v > SETTINGS_VERSION => {
1278 anyhow::bail!(
1280 "Settings version {} is newer than supported version {}. \
1281 Please update oxi.",
1282 v,
1283 SETTINGS_VERSION
1284 );
1285 }
1286 v => {
1287 tracing::warn!(
1289 "Unknown settings version {}, attempting migration to {}",
1290 v,
1291 SETTINGS_VERSION
1292 );
1293 settings.version = SETTINGS_VERSION;
1294 }
1295 }
1296
1297 Ok(settings)
1298 }
1299}
1300
1301#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1305pub enum SettingsFormat {
1306 #[default]
1308 Json,
1309 Toml,
1311}
1312
1313impl SettingsFormat {
1314 pub fn extension(&self) -> &'static str {
1316 match self {
1317 SettingsFormat::Json => "json",
1318 SettingsFormat::Toml => "toml",
1319 }
1320 }
1321}
1322
1323fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1327 match toml {
1328 toml::Value::String(s) => serde_json::Value::String(s),
1329 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1330 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1331 .map(serde_json::Value::Number)
1332 .unwrap_or(serde_json::Value::Null),
1333 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1334 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1335 toml::Value::Array(arr) => {
1336 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1337 }
1338 toml::Value::Table(table) => {
1339 let obj = table
1340 .into_iter()
1341 .map(|(k, v)| (k, toml_value_to_json(v)))
1342 .collect();
1343 serde_json::Value::Object(obj)
1344 }
1345 }
1346}
1347
1348fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1350 match (base, override_) {
1351 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1353 let mut result = base_map;
1354 for (key, override_value) in override_map {
1355 let base_value = result.remove(&key);
1356 let merged = match base_value {
1357 Some(base_v) => merge_json_values(base_v, override_value),
1358 None => override_value,
1359 };
1360 result.insert(key, merged);
1361 }
1362 serde_json::Value::Object(result)
1363 }
1364 (_, override_) => override_,
1366 }
1367}
1368
1369pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1371 match s.to_lowercase().as_str() {
1372 "off" | "none" => Some(ThinkingLevel::Off),
1373 "minimal" => Some(ThinkingLevel::Minimal),
1374 "low" => Some(ThinkingLevel::Low),
1375 "medium" | "standard" => Some(ThinkingLevel::Medium),
1376 "high" | "thorough" => Some(ThinkingLevel::High),
1377 "xhigh" => Some(ThinkingLevel::XHigh),
1378 _ => None,
1379 }
1380}
1381
1382#[allow(dead_code)]
1384fn parse_boolish(s: &str) -> Result<bool> {
1385 match s.to_lowercase().as_str() {
1386 "true" | "1" | "yes" | "on" => Ok(true),
1387 "false" | "0" | "no" | "off" => Ok(false),
1388 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1389 }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::*;
1395 use std::io::Write as IoWrite;
1396 use std::sync::Mutex;
1397
1398 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1401
1402 struct EnvGuard {
1405 saved: Vec<(String, Option<String>)>,
1406 }
1407
1408 impl EnvGuard {
1409 fn new(vars: &[&str]) -> Self {
1410 let saved = vars
1411 .iter()
1412 .map(|&name| {
1413 let old = env::var(name).ok();
1414 unsafe { env::remove_var(name) };
1416 (name.to_string(), old)
1417 })
1418 .collect();
1419 Self { saved }
1420 }
1421 }
1422
1423 impl Drop for EnvGuard {
1424 fn drop(&mut self) {
1425 for (name, old) in self.saved.drain(..) {
1426 match old {
1427 Some(val) => unsafe { env::set_var(&name, val) },
1429 None => unsafe { env::remove_var(&name) },
1430 }
1431 }
1432 }
1433 }
1434
1435 #[test]
1438 fn test_default_settings() {
1439 let settings = Settings::default();
1440 assert_eq!(settings.version, SETTINGS_VERSION);
1441 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1442 assert_eq!(settings.theme, "default");
1443 assert!(settings.last_used_model.is_none());
1444 assert!(settings.last_used_provider.is_none());
1445 assert!(settings.extensions_enabled);
1446 assert!(settings.auto_compaction);
1447 assert_eq!(settings.tool_timeout_seconds, 120);
1448 assert!(settings.stream_responses);
1449 }
1450
1451 #[test]
1452 fn test_merge_cli() {
1453 let mut settings = Settings::default();
1454 settings.last_used_model = Some("gpt-4o".to_string());
1455
1456 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1457 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1458
1459 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1460 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1461
1462 settings.merge_cli(
1464 None,
1465 None,
1466 Some(true),
1467 Some(false),
1468 Some(vec!["openai/gpt-4o".to_string()]),
1469 Some(false),
1470 );
1471 assert!(settings.enable_routing);
1472 assert!(!settings.prefer_cost_efficient);
1473 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1474 assert!(!settings.disable_fallback);
1475
1476 let mut settings2 = Settings::default();
1478 settings2.merge_cli(None, None, None, None, None, Some(true));
1479 assert!(settings2.disable_fallback);
1480 assert!(!settings2.enable_fallback);
1481 }
1482
1483 #[test]
1486 fn test_layer_file_overrides() {
1487 let base = Settings::default();
1488
1489 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1490 let toml_content = r#"
1491last_used_model = "openai/gpt-4o"
1492theme = "dracula"
1493"#;
1494 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1495
1496 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1497 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1498 assert_eq!(merged.theme, "dracula");
1499 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1501 assert!(merged.extensions_enabled);
1502 }
1503
1504 #[test]
1505 fn test_layer_file_preserves_unset() {
1506 let mut base = Settings::default();
1507 base.last_used_provider = Some("deepseek".to_string());
1508
1509 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1510 let toml_content = "theme = \"monokai\"\n";
1512 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1513
1514 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1515 assert_eq!(merged.theme, "monokai");
1516 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1517 }
1518
1519 #[test]
1520 fn test_load_from_dir_with_project_config() {
1521 let _guard = EnvGuard::new(&[
1522 "OXI_MODEL",
1523 "OXI_PROVIDER",
1524 "OXI_THEME",
1525 "OXI_TOOL_TIMEOUT",
1526 "OXI_TEMPERATURE",
1527 "OXI_MAX_TOKENS",
1528 "OXI_SESSION_DIR",
1529 "OXI_STREAM",
1530 "OXI_EXTENSIONS_ENABLED",
1531 ]);
1532 let tmp = tempfile::tempdir().unwrap();
1533 let oxi_dir = tmp.path().join(".oxi");
1534 fs::create_dir_all(&oxi_dir).unwrap();
1535 let settings_path = oxi_dir.join("settings.toml");
1536 fs::write(
1538 &settings_path,
1539 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1540 )
1541 .unwrap();
1542
1543 let settings = Settings::load_from(tmp.path()).unwrap();
1544 assert_eq!(
1546 settings.last_used_model,
1547 Some("gemini-2.0-flash".to_string())
1548 );
1549 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1550 }
1551
1552 #[test]
1553 fn test_load_from_dir_no_config() {
1554 let _guard = EnvGuard::new(&[
1556 "OXI_MODEL",
1557 "OXI_PROVIDER",
1558 "OXI_THEME",
1559 "OXI_TOOL_TIMEOUT",
1560 "OXI_TEMPERATURE",
1561 "OXI_MAX_TOKENS",
1562 "OXI_SESSION_DIR",
1563 "OXI_STREAM",
1564 "OXI_EXTENSIONS_ENABLED",
1565 ]);
1566 let tmp = tempfile::tempdir().unwrap();
1567 let global = tmp.path().join("nonexistent-settings.json");
1572 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1573 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1574 }
1575 #[test]
1576 fn test_from_env() {
1577 let _guard = EnvGuard::new(&[
1580 "OXI_MODEL",
1582 "OXI_THEME",
1583 "OXI_TOOL_TIMEOUT",
1584 "OXI_PROVIDER",
1585 "OXI_DEFAULT_MODEL",
1586 ]);
1587
1588 let settings = Settings::from_env();
1589 assert_eq!(settings.last_used_model, None);
1591 assert_eq!(settings.theme, "default");
1592 assert_eq!(settings.tool_timeout_seconds, 120);
1593 }
1594
1595 #[test]
1596 fn test_apply_env_boolish() {
1597 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1600 unsafe { env::set_var("OXI_STREAM", "false") };
1601 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1602
1603 let mut settings = Settings::default();
1604 settings.apply_env();
1605 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1609
1610 #[test]
1611 fn test_apply_env_temperature() {
1612 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1614 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1615
1616 let mut settings = Settings::default();
1617 settings.apply_env();
1618 assert_eq!(settings.default_temperature, None);
1620 }
1621
1622 #[test]
1623 fn test_env_does_not_override_when_unset() {
1624 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1625 let settings = Settings::from_env();
1626 assert!(settings.last_used_model.is_none());
1627 assert!(settings.last_used_provider.is_none());
1628 }
1629
1630 #[test]
1631 fn test_parse_thinking_level() {
1632 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1633 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1634 assert_eq!(
1635 parse_thinking_level("MINIMAL"),
1636 Some(ThinkingLevel::Minimal)
1637 );
1638 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1639 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1640 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1641 assert_eq!(
1642 parse_thinking_level("Standard"),
1643 Some(ThinkingLevel::Medium)
1644 );
1645 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1646 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1647 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1648 assert_eq!(parse_thinking_level("invalid"), None);
1649 }
1650
1651 #[test]
1652 fn test_parse_boolish() {
1653 assert!(parse_boolish("true").unwrap());
1654 assert!(parse_boolish("1").unwrap());
1655 assert!(parse_boolish("yes").unwrap());
1656 assert!(parse_boolish("ON").unwrap());
1657 assert!(!parse_boolish("false").unwrap());
1658 assert!(!parse_boolish("0").unwrap());
1659 assert!(!parse_boolish("no").unwrap());
1660 assert!(!parse_boolish("OFF").unwrap());
1661 assert!(parse_boolish("maybe").is_err());
1662 }
1663
1664 #[test]
1667 fn test_effective_model_returns_last_used() {
1668 let mut settings = Settings::default();
1669 settings.last_used_model = Some("openai/gpt-4o".to_string());
1670 assert_eq!(
1671 settings.effective_model(None),
1672 Some("openai/gpt-4o".to_string())
1673 );
1674 }
1675
1676 #[test]
1677 fn test_effective_model_cli_overrides() {
1678 let mut settings = Settings::default();
1679 settings.last_used_model = Some("openai/gpt-4o".to_string());
1680 assert_eq!(
1681 settings.effective_model(Some("anthropic/claude-3")),
1682 Some("anthropic/claude-3".to_string())
1683 );
1684 }
1685
1686 #[test]
1687 fn test_effective_model_none_when_unset() {
1688 let settings = Settings::default();
1689 assert_eq!(settings.effective_model(None), None);
1690 }
1691
1692 #[test]
1693 fn test_effective_model_falls_back_to_last_used() {
1694 let mut settings = Settings::default();
1695 settings.last_used_model = Some("anthropic/claude-3".to_string());
1696 assert_eq!(
1697 settings.effective_model(None),
1698 Some("anthropic/claude-3".to_string())
1699 );
1700 }
1701
1702 #[test]
1703 fn test_effective_model_returns_none_when_nothing_set() {
1704 let settings = Settings::default();
1705 assert_eq!(settings.effective_model(None), None);
1706 }
1707
1708 #[test]
1709 fn test_effective_temperature_prefers_f64() {
1710 let mut settings = Settings::default();
1711 settings.temperature = Some(0.5);
1712 settings.default_temperature = Some(0.7);
1713 assert_eq!(settings.effective_temperature(), Some(0.7));
1714 }
1715
1716 #[test]
1717 fn test_effective_temperature_falls_back_to_f32() {
1718 let mut settings = Settings::default();
1719 settings.temperature = Some(0.5);
1720 assert_eq!(settings.effective_temperature(), Some(0.5));
1721 }
1722
1723 #[test]
1724 fn test_effective_max_tokens_prefers_usize() {
1725 let mut settings = Settings::default();
1726 settings.max_tokens = Some(1024);
1727 settings.max_response_tokens = Some(4096);
1728 assert_eq!(settings.effective_max_tokens(), Some(4096));
1729 }
1730
1731 #[test]
1732 fn test_effective_max_tokens_falls_back_to_u32() {
1733 let mut settings = Settings::default();
1734 settings.max_tokens = Some(1024);
1735 assert_eq!(settings.effective_max_tokens(), Some(1024));
1736 }
1737
1738 #[test]
1741 fn test_effective_session_dir_default() {
1742 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1743 let settings = Settings::default();
1744 let dir = settings.effective_session_dir().unwrap();
1745 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1746 }
1747
1748 #[test]
1749 fn test_effective_session_dir_from_field() {
1750 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1751 let mut settings = Settings::default();
1752 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1753 assert_eq!(
1754 settings.effective_session_dir().unwrap(),
1755 PathBuf::from("/tmp/oxi-sessions")
1756 );
1757 }
1758
1759 #[test]
1760 fn test_effective_session_dir_env_disabled() {
1761 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1764 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1765 let settings = Settings::default();
1766 let dir = settings.effective_session_dir().unwrap();
1768 assert!(
1769 dir.ends_with("sessions"),
1770 "expected default sessions dir, got: {:?}",
1771 dir
1772 );
1773 }
1774
1775 #[test]
1778 fn test_migration_v0_to_v1() {
1779 let mut settings = Settings::default();
1780 settings.version = 0;
1781 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1784 assert_eq!(migrated.version, SETTINGS_VERSION);
1785 assert_eq!(migrated.tool_timeout_seconds, 120);
1786 }
1787
1788 #[test]
1789 fn test_migration_already_current() {
1790 let settings = Settings::default();
1791 let migrated = Settings::migrate(settings).unwrap();
1792 assert_eq!(migrated.version, SETTINGS_VERSION);
1793 }
1794
1795 #[test]
1796 fn test_migration_v3_to_v4_splits_model() {
1797 let mut settings = Settings::default();
1798 settings.version = 3;
1799 settings.default_model = Some("openai/gpt-4o".to_string());
1800 settings.default_provider = None;
1801
1802 let migrated = Settings::migrate(settings).unwrap();
1803 assert_eq!(migrated.version, SETTINGS_VERSION);
1804 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1805 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1806 }
1807
1808 #[test]
1809 fn test_migration_v3_no_slash_keeps_model() {
1810 let mut settings = Settings::default();
1811 settings.version = 3;
1812 settings.default_model = Some("bare-model-name".to_string());
1813
1814 let migrated = Settings::migrate(settings).unwrap();
1815 assert_eq!(migrated.version, SETTINGS_VERSION);
1816 assert_eq!(
1817 migrated.last_used_model,
1818 Some("bare-model-name".to_string())
1819 );
1820 }
1821
1822 #[test]
1823 fn test_migration_future_version_fails() {
1824 let mut settings = Settings::default();
1825 settings.version = 9999;
1826 assert!(Settings::migrate(settings).is_err());
1827 }
1828
1829 #[test]
1832 fn test_default_output_languages_is_empty() {
1833 let settings = Settings::default();
1834 assert!(
1835 settings.output_languages.is_empty(),
1836 "all channels should default to auto (empty map)"
1837 );
1838 }
1839
1840 #[test]
1841 fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1842 let mut settings = Settings::default();
1843 settings.version = 4;
1844 settings
1845 .output_languages
1846 .insert("response".to_string(), "ko".to_string());
1847 settings
1848 .output_languages
1849 .insert("commit_message".to_string(), "en".to_string());
1850
1851 let migrated = Settings::migrate(settings).unwrap();
1852 assert_eq!(migrated.version, SETTINGS_VERSION);
1853 assert_eq!(
1854 migrated.output_languages.get("response"),
1855 Some(&"ko".to_string())
1856 );
1857 assert_eq!(
1858 migrated.output_languages.get("commit_message"),
1859 Some(&"en".to_string())
1860 );
1861 }
1862
1863 #[test]
1864 fn test_migration_v4_to_v5_creates_empty_if_missing() {
1865 let mut settings = Settings::default();
1869 settings.version = 4;
1870 assert!(settings.output_languages.is_empty());
1871
1872 let migrated = Settings::migrate(settings).unwrap();
1873 assert_eq!(migrated.version, SETTINGS_VERSION);
1874 assert!(migrated.output_languages.is_empty());
1875 }
1876
1877 #[test]
1878 fn test_validate_keeps_user_defined_channel() {
1879 let mut settings = Settings::default();
1884 settings
1885 .output_languages
1886 .insert("pr_description".to_string(), "en".to_string()); settings
1888 .output_languages
1889 .insert("response".to_string(), "ko".to_string()); settings.validate_output_languages();
1892
1893 assert!(settings.output_languages.contains_key("pr_description"));
1894 assert!(settings.output_languages.contains_key("response"));
1895 assert_eq!(
1896 settings.output_languages.get("pr_description"),
1897 Some(&"en".to_string())
1898 );
1899 assert_eq!(
1900 settings.output_languages.get("response"),
1901 Some(&"ko".to_string())
1902 );
1903 }
1904
1905 #[test]
1906 fn test_validate_keeps_unknown_lang_with_warning() {
1907 let mut settings = Settings::default();
1908 settings
1909 .output_languages
1910 .insert("response".to_string(), "klingon".to_string()); settings
1912 .output_languages
1913 .insert("commit_message".to_string(), "en".to_string()); settings.validate_output_languages();
1916
1917 assert_eq!(
1920 settings.output_languages.get("response"),
1921 Some(&"klingon".to_string())
1922 );
1923 assert_eq!(
1924 settings.output_languages.get("commit_message"),
1925 Some(&"en".to_string())
1926 );
1927 }
1928
1929 #[test]
1930 fn test_known_channels_table_includes_core_four() {
1931 let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1932 assert!(keys.contains(&"response"));
1933 assert!(keys.contains(&"code_comment"));
1934 assert!(keys.contains(&"documentation"));
1935 assert!(keys.contains(&"commit_message"));
1936 }
1937
1938 #[test]
1939 fn test_known_langs_table_includes_auto_and_english() {
1940 let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1941 assert!(codes.contains(&"auto"));
1942 assert!(codes.contains(&"en"));
1943 }
1944
1945 #[test]
1946 fn test_default_language_policy_enabled_is_false() {
1947 let settings = Settings::default();
1949 assert!(
1950 !settings.language_policy_enabled,
1951 "language_policy_enabled must default to false (opt-in)"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_migration_v5_to_v6_defaults_master_toggle_to_off() {
1957 let mut settings = Settings::default();
1961 settings.version = 5;
1962 settings
1963 .output_languages
1964 .insert("response".to_string(), "ko".to_string());
1965 settings
1966 .output_languages
1967 .insert("commit_message".to_string(), "en".to_string());
1968
1969 let migrated = Settings::migrate(settings).unwrap();
1970 assert_eq!(migrated.version, SETTINGS_VERSION);
1971 assert!(
1972 !migrated.language_policy_enabled,
1973 "v5 → v6 migration must default language_policy_enabled to false"
1974 );
1975 assert_eq!(
1977 migrated.output_languages.get("response"),
1978 Some(&"ko".to_string())
1979 );
1980 assert_eq!(
1981 migrated.output_languages.get("commit_message"),
1982 Some(&"en".to_string())
1983 );
1984 }
1985
1986 #[test]
1987 fn test_default_glyph_set_is_unicode() {
1988 let settings = Settings::default();
1989 assert_eq!(
1990 settings.glyph_set,
1991 GlyphSet::Unicode,
1992 "glyph_set must default to Unicode"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1998 let mut settings = Settings::default();
2001 settings.version = 7;
2002 settings.glyph_set = GlyphSet::default();
2004
2005 let migrated = Settings::migrate(settings).unwrap();
2006 assert_eq!(migrated.version, SETTINGS_VERSION);
2007 assert_eq!(
2008 migrated.glyph_set,
2009 GlyphSet::Unicode,
2010 "v7 → v8 migration must default glyph_set to unicode"
2011 );
2012 }
2013
2014 #[test]
2015 fn test_glyph_set_persists_through_roundtrip() {
2016 let mut original = Settings::default();
2020 original.glyph_set = GlyphSet::Nerd;
2021 let content = toml::to_string_pretty(&original).unwrap();
2022 assert!(
2023 content.contains("glyph_set = \"nerd\""),
2024 "nerd preset must serialize to snake_case; got:\n{content}"
2025 );
2026 let loaded: Settings = toml::from_str(&content).unwrap();
2027 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
2028 original.glyph_set = GlyphSet::Unicode;
2030 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
2031 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
2032 }
2033
2034 #[test]
2035 fn test_save_and_load_roundtrip_preserves_language_policy_enabled() {
2036 let tmp = tempfile::tempdir().unwrap();
2037 let settings_path = tmp.path().join("settings.toml");
2038
2039 let mut original = Settings::default();
2040 original.language_policy_enabled = true;
2041 original
2042 .output_languages
2043 .insert("response".to_string(), "ko".to_string());
2044
2045 let content = toml::to_string_pretty(&original).unwrap();
2046 fs::write(&settings_path, &content).unwrap();
2047
2048 let loaded_content = fs::read_to_string(&settings_path).unwrap();
2049 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2050
2051 assert!(loaded.language_policy_enabled);
2052 assert_eq!(
2053 loaded.output_languages.get("response"),
2054 Some(&"ko".to_string())
2055 );
2056 }
2057
2058 #[test]
2059 fn test_save_and_load_roundtrip_preserves_output_languages() {
2060 let tmp = tempfile::tempdir().unwrap();
2061 let settings_path = tmp.path().join("settings.toml");
2062
2063 let mut original = Settings::default();
2064 original
2065 .output_languages
2066 .insert("response".to_string(), "ko".to_string());
2067 original
2068 .output_languages
2069 .insert("commit_message".to_string(), "en".to_string());
2070
2071 let content = toml::to_string_pretty(&original).unwrap();
2072 fs::write(&settings_path, &content).unwrap();
2073
2074 let loaded_content = fs::read_to_string(&settings_path).unwrap();
2075 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2076
2077 assert_eq!(
2078 loaded.output_languages.get("response"),
2079 Some(&"ko".to_string())
2080 );
2081 assert_eq!(
2082 loaded.output_languages.get("commit_message"),
2083 Some(&"en".to_string())
2084 );
2085 }
2086
2087 #[test]
2090 fn test_save_and_load_roundtrip() {
2091 let tmp = tempfile::tempdir().unwrap();
2092 let settings_path = tmp.path().join("settings.toml");
2093
2094 let mut original = Settings::default();
2095 original.last_used_model = Some("gpt-4o".to_string());
2096 original.last_used_provider = Some("openai".to_string());
2097 original.theme = "dracula".to_string();
2098 original.tool_timeout_seconds = 60;
2099
2100 let content = toml::to_string_pretty(&original).unwrap();
2102 fs::write(&settings_path, &content).unwrap();
2103
2104 let loaded_content = fs::read_to_string(&settings_path).unwrap();
2106 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2107
2108 assert_eq!(loaded.last_used_model, original.last_used_model);
2109 assert_eq!(loaded.theme, original.theme);
2110 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
2111 }
2112
2113 #[test]
2114 fn test_toml_roundtrip_preserves_new_fields() {
2115 let mut settings = Settings::default();
2116 settings.default_temperature = Some(0.8);
2117 settings.max_response_tokens = Some(8192);
2118 settings.auto_compaction = false;
2119 settings.extensions_enabled = false;
2120 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
2121
2122 let toml_str = toml::to_string_pretty(&settings).unwrap();
2123 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2124
2125 assert_eq!(parsed.default_temperature, Some(0.8));
2126 assert_eq!(parsed.max_response_tokens, Some(8192));
2127 assert!(!parsed.auto_compaction);
2128 assert!(!parsed.extensions_enabled);
2129 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
2130 }
2131
2132 #[test]
2135 fn test_json_roundtrip() {
2136 let mut settings = Settings::default();
2137 settings.last_used_model = Some("gpt-4o".to_string());
2138 settings.last_used_provider = Some("openai".to_string());
2139 settings.theme = "dracula".to_string();
2140 settings.tool_timeout_seconds = 60;
2141 settings.default_temperature = Some(0.8);
2142 settings.max_response_tokens = Some(8192);
2143
2144 let json_str = serde_json::to_string_pretty(&settings).unwrap();
2145 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
2146
2147 assert_eq!(parsed.last_used_model, settings.last_used_model);
2148 assert_eq!(parsed.theme, settings.theme);
2149 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
2150 assert_eq!(parsed.default_temperature, settings.default_temperature);
2151 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
2152 }
2153
2154 #[test]
2155 fn test_json_serialize_for_format() {
2156 let mut settings = Settings::default();
2157 settings.last_used_model = Some("claude-3".to_string());
2158 settings.last_used_provider = Some("anthropic".to_string());
2159 settings.thinking_level = ThinkingLevel::Minimal;
2160
2161 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
2162 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
2163
2164 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
2165 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
2166 }
2167
2168 #[test]
2169 fn test_toml_serialize_for_format() {
2170 let mut settings = Settings::default();
2171 settings.last_used_model = Some("gemini-pro".to_string());
2172 settings.last_used_provider = Some("google".to_string());
2173 settings.thinking_level = ThinkingLevel::High;
2174
2175 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
2176 let parsed: Settings = toml::from_str(&toml_content).unwrap();
2177
2178 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2179 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
2180 }
2181
2182 #[test]
2183 fn test_parse_from_str_json() {
2184 let json_content = r#"{
2185 "last_used_model": "gpt-4",
2186 "last_used_provider": "openai",
2187 "theme": "nord",
2188 "tool_timeout_seconds": 90
2189 }"#;
2190
2191 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
2192 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
2193 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
2194 assert_eq!(settings.theme, "nord");
2195 assert_eq!(settings.tool_timeout_seconds, 90);
2196 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2198 assert!(settings.extensions_enabled);
2199 }
2200
2201 #[test]
2202 fn test_parse_from_str_toml() {
2203 let toml_content = r#"
2204last_used_model = "claude-opus"
2205last_used_provider = "anthropic"
2206theme = "monokai"
2207tool_timeout_seconds = 45
2208"#;
2209
2210 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
2211 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
2212 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
2213 assert_eq!(settings.theme, "monokai");
2214 assert_eq!(settings.tool_timeout_seconds, 45);
2215 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2216 }
2217
2218 #[test]
2219 fn test_layer_file_json() {
2220 let base = Settings::default();
2221
2222 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2223 let json_content = r#"{
2224 "last_used_model": "gpt-4o",
2225 "last_used_provider": "openai",
2226 "theme": "dracula",
2227 "auto_compaction": false
2228 }"#;
2229 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2230
2231 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2232 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
2233 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
2234 assert_eq!(merged.theme, "dracula");
2235 assert!(!merged.auto_compaction);
2236 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
2238 assert!(merged.extensions_enabled);
2239 assert_eq!(merged.tool_timeout_seconds, 120);
2240 }
2241
2242 #[test]
2243 fn test_layer_file_json_preserves_unset() {
2244 let mut base = Settings::default();
2245 base.last_used_provider = Some("deepseek".to_string());
2246
2247 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2248 let json_content = r#"{ "theme": "nord" }"#;
2249 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2250
2251 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2252 assert_eq!(merged.theme, "nord");
2253 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
2254 }
2255
2256 #[test]
2257 fn test_save_to_json() {
2258 let tmp = tempfile::tempdir().unwrap();
2259 let settings_path = tmp.path().join("settings.json");
2260
2261 let mut settings = Settings::default();
2262 settings.last_used_model = Some("gpt-4o".to_string());
2263 settings.last_used_provider = Some("openai".to_string());
2264 settings.theme = "dracula".to_string();
2265 settings.tool_timeout_seconds = 60;
2266
2267 settings.save_to(&settings_path).unwrap();
2268
2269 let content = fs::read_to_string(&settings_path).unwrap();
2271 let parsed: Settings = serde_json::from_str(&content).unwrap();
2272 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
2273 assert_eq!(parsed.theme, "dracula");
2274 assert_eq!(parsed.tool_timeout_seconds, 60);
2275 }
2276
2277 #[test]
2278 fn test_save_to_toml() {
2279 let tmp = tempfile::tempdir().unwrap();
2280 let settings_path = tmp.path().join("settings.toml");
2281
2282 let mut settings = Settings::default();
2283 settings.last_used_model = Some("gemini-pro".to_string());
2284 settings.last_used_provider = Some("google".to_string());
2285 settings.theme = "monokai".to_string();
2286 settings.tool_timeout_seconds = 90;
2287
2288 settings.save_to(&settings_path).unwrap();
2289
2290 let content = fs::read_to_string(&settings_path).unwrap();
2292 let parsed: Settings = toml::from_str(&content).unwrap();
2293 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2294 assert_eq!(parsed.theme, "monokai");
2295 assert_eq!(parsed.tool_timeout_seconds, 90);
2296 }
2297
2298 #[test]
2299 fn test_load_from_dir_with_json_project_config() {
2300 let _guard = EnvGuard::new(&[
2301 "OXI_MODEL",
2302 "OXI_PROVIDER",
2303 "OXI_THEME",
2304 "OXI_TOOL_TIMEOUT",
2305 "OXI_TEMPERATURE",
2306 "OXI_MAX_TOKENS",
2307 "OXI_SESSION_DIR",
2308 "OXI_STREAM",
2309 "OXI_EXTENSIONS_ENABLED",
2310 ]);
2311 let tmp = tempfile::tempdir().unwrap();
2312 let oxi_dir = tmp.path().join(".oxi");
2313 fs::create_dir_all(&oxi_dir).unwrap();
2314 let settings_path = oxi_dir.join("settings.json");
2315 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
2317 fs::write(&settings_path, json_content).unwrap();
2318
2319 let settings = Settings::load_from(tmp.path()).unwrap();
2320 assert_eq!(
2322 settings.last_used_model,
2323 Some("gemini-2.0-flash".to_string())
2324 );
2325 assert_eq!(settings.last_used_provider, Some("google".to_string()));
2326 }
2327
2328 #[test]
2329 fn test_find_project_settings_json_priority() {
2330 let tmp = tempfile::tempdir().unwrap();
2331 let oxi_dir = tmp.path().join(".oxi");
2332 fs::create_dir_all(&oxi_dir).unwrap();
2333
2334 let json_path = oxi_dir.join("settings.json");
2336 let toml_path = oxi_dir.join("settings.toml");
2337 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2338 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2339
2340 let found = Settings::find_project_settings(tmp.path());
2342 assert!(found.is_some());
2343 assert_eq!(
2344 found.unwrap().file_name().unwrap().to_str().unwrap(),
2345 "settings.json"
2346 );
2347 }
2348
2349 #[test]
2350 fn test_find_project_settings_json_only() {
2351 let tmp = tempfile::tempdir().unwrap();
2352 let oxi_dir = tmp.path().join(".oxi");
2353 fs::create_dir_all(&oxi_dir).unwrap();
2354
2355 let json_path = oxi_dir.join("settings.json");
2356 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2357
2358 let found = Settings::find_project_settings(tmp.path());
2359 assert!(found.is_some());
2360 assert_eq!(
2361 found.unwrap().file_name().unwrap().to_str().unwrap(),
2362 "settings.json"
2363 );
2364 }
2365
2366 #[test]
2367 fn test_find_project_settings_toml_fallback() {
2368 let tmp = tempfile::tempdir().unwrap();
2369 let oxi_dir = tmp.path().join(".oxi");
2370 fs::create_dir_all(&oxi_dir).unwrap();
2371
2372 let toml_path = oxi_dir.join("settings.toml");
2373 fs::write(&toml_path, r#"theme = "test""#).unwrap();
2374
2375 let found = Settings::find_project_settings(tmp.path());
2376 assert!(found.is_some());
2377 assert_eq!(
2378 found.unwrap().file_name().unwrap().to_str().unwrap(),
2379 "settings.toml"
2380 );
2381 }
2382
2383 #[test]
2384 fn test_detect_format() {
2385 let json_path = PathBuf::from("/test/settings.json");
2386 let toml_path = PathBuf::from("/test/settings.toml");
2387 let unknown_path = PathBuf::from("/test/settings");
2388
2389 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2390 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2391 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2392 }
2394
2395 #[test]
2396 fn test_settings_format_extension() {
2397 assert_eq!(SettingsFormat::Json.extension(), "json");
2398 assert_eq!(SettingsFormat::Toml.extension(), "toml");
2399 }
2400
2401 #[test]
2402 fn test_layer_json_over_toml() {
2403 let tmp = tempfile::tempdir().unwrap();
2405 let oxi_dir = tmp.path().join(".oxi");
2406 fs::create_dir_all(&oxi_dir).unwrap();
2407
2408 let json_path = oxi_dir.join("settings.json");
2409 let toml_path = oxi_dir.join("settings.toml");
2410
2411 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2413 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2415
2416 let settings = Settings::load_from(tmp.path()).unwrap();
2418 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2419 }
2420
2421 #[test]
2422 fn test_mixed_format_loading() {
2423 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2425 let toml_content = r#"
2426last_used_model = "loaded-via-toml"
2427theme = "loaded-theme"
2428stream_responses = false
2429"#;
2430 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2431
2432 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2433 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2434 assert_eq!(merged.theme, "loaded-theme");
2435 assert!(!merged.stream_responses);
2436 }
2437
2438 #[test]
2439 fn test_merge_json_values() {
2440 let base = serde_json::json!({
2441 "version": 1,
2442 "theme": "default",
2443 "extensions": ["ext1"],
2444 "nested": {
2445 "a": 1,
2446 "b": 2
2447 }
2448 });
2449
2450 let override_ = serde_json::json!({
2451 "version": 2,
2452 "theme": "dark",
2453 "extensions": ["ext2"],
2454 "nested": {
2455 "b": 20,
2456 "c": 30
2457 }
2458 });
2459
2460 let merged = merge_json_values(base, override_);
2461
2462 assert_eq!(merged["version"], 2);
2463 assert_eq!(merged["theme"], "dark");
2464 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2466 assert_eq!(merged["nested"]["a"], 1);
2468 assert_eq!(merged["nested"]["b"], 20);
2469 assert_eq!(merged["nested"]["c"], 30);
2470 }
2471
2472 #[test]
2473 fn test_save_project_preserves_existing_format() {
2474 let tmp = tempfile::tempdir().unwrap();
2475 let oxi_dir = tmp.path().join(".oxi");
2476 fs::create_dir_all(&oxi_dir).unwrap();
2477
2478 let toml_path = oxi_dir.join("settings.toml");
2480 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2481
2482 let mut settings = Settings::default();
2483 settings.theme = "new-theme".to_string();
2484 settings.save_project(tmp.path()).unwrap();
2485
2486 let content = fs::read_to_string(&toml_path).unwrap();
2488 assert!(content.contains("new-theme"));
2489 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2490 }
2491
2492 #[test]
2493 fn test_save_project_creates_json_by_default() {
2494 let tmp = tempfile::tempdir().unwrap();
2495 let oxi_dir = tmp.path().join(".oxi");
2496 fs::create_dir_all(&oxi_dir).unwrap();
2497 let mut settings = Settings::default();
2500 settings.theme = "json-theme".to_string();
2501 settings.save_project(tmp.path()).unwrap();
2502
2503 let json_path = oxi_dir.join("settings.json");
2505 assert!(json_path.exists());
2506 let content = fs::read_to_string(&json_path).unwrap();
2507 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2508 assert!(content.contains("json-theme"));
2509 }
2510
2511 #[test]
2514 fn test_custom_provider_default_api() {
2515 use super::CustomProvider;
2516 let cp = CustomProvider {
2517 name: "test".to_string(),
2518 base_url: "https://api.test.com/v1".to_string(),
2519 api_key_env: "TEST_API_KEY".to_string(),
2520 api: super::default_custom_provider_api(),
2521 };
2522 assert_eq!(cp.api, "openai-completions");
2523 }
2524
2525 #[test]
2526 fn test_custom_provider_toml_deserialize() {
2527 let toml_content = r#"
2528[[custom_providers]]
2529name = "minimax"
2530base_url = "https://api.minimax.chat/v1"
2531api_key_env = "MINIMAX_API_KEY"
2532api = "openai-completions"
2533
2534[[custom_providers]]
2535name = "zai"
2536base_url = "https://api.z.ai/v1"
2537api_key_env = "ZAI_API_KEY"
2538api = "openai-responses"
2539"#;
2540 let settings: Settings = toml::from_str(toml_content).unwrap();
2541 assert_eq!(settings.custom_providers.len(), 2);
2542 assert_eq!(settings.custom_providers[0].name, "minimax");
2543 assert_eq!(
2544 settings.custom_providers[0].base_url,
2545 "https://api.minimax.chat/v1"
2546 );
2547 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2548 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2549 assert_eq!(settings.custom_providers[1].name, "zai");
2550 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2551 }
2552
2553 #[test]
2554 fn test_custom_provider_json_deserialize() {
2555 let json_content = r#"{
2556 "custom_providers": [
2557 {
2558 "name": "minimax",
2559 "base_url": "https://api.minimax.chat/v1",
2560 "api_key_env": "MINIMAX_API_KEY",
2561 "api": "openai-completions"
2562 }
2563 ]
2564 }"#;
2565 let settings: Settings = serde_json::from_str(json_content).unwrap();
2566 assert_eq!(settings.custom_providers.len(), 1);
2567 assert_eq!(settings.custom_providers[0].name, "minimax");
2568 }
2569
2570 #[test]
2571 fn test_custom_provider_toml_roundtrip() {
2572 let mut settings = Settings::default();
2573 settings.custom_providers.push(super::CustomProvider {
2574 name: "test".to_string(),
2575 base_url: "https://api.test.com/v1".to_string(),
2576 api_key_env: "TEST_API_KEY".to_string(),
2577 api: "openai-completions".to_string(),
2578 });
2579
2580 let toml_str = toml::to_string_pretty(&settings).unwrap();
2581 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2582 assert_eq!(parsed.custom_providers.len(), 1);
2583 assert_eq!(parsed.custom_providers[0].name, "test");
2584 assert_eq!(
2585 parsed.custom_providers[0].base_url,
2586 "https://api.test.com/v1"
2587 );
2588 }
2589
2590 #[test]
2591 fn test_custom_provider_defaults_empty() {
2592 let settings = Settings::default();
2593 assert!(settings.custom_providers.is_empty());
2594 }
2595
2596 #[test]
2597 fn test_custom_provider_layer_file() {
2598 let base = Settings::default();
2599
2600 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2601 let toml_content = r#"
2602[[custom_providers]]
2603name = "my-provider"
2604base_url = "https://api.my-provider.com/v1"
2605api_key_env = "MY_PROVIDER_API_KEY"
2606"#;
2607 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2608
2609 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2610 assert_eq!(merged.custom_providers.len(), 1);
2611 assert_eq!(merged.custom_providers[0].name, "my-provider");
2612 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2614 }
2615}