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 extensions_enabled: bool,
211
212 #[serde(default = "default_true")]
214 pub auto_compaction: bool,
215
216 #[serde(default)]
219 pub disabled_tools: Vec<String>,
220
221 #[serde(default = "default_tool_timeout")]
224 pub tool_timeout_seconds: u64,
225
226 #[serde(default, alias = "questionnaire_timeout_secs")]
229 pub ask_timeout_secs: u64,
230
231 #[serde(default)]
234 pub extensions: Vec<String>,
235
236 #[serde(default)]
238 pub skills: Vec<String>,
239
240 #[serde(default)]
242 pub prompts: Vec<String>,
243
244 #[serde(default)]
246 pub themes: Vec<String>,
247
248 #[serde(default)]
251 pub custom_providers: Vec<CustomProvider>,
252
253 #[serde(default)]
258 pub dynamic_models: HashMap<String, Vec<String>>,
259
260 #[serde(default = "default_false")]
263 pub enable_routing: bool,
264
265 #[serde(default)]
267 pub router_profile: Option<String>,
268
269 #[serde(default = "default_true")]
271 pub prefer_cost_efficient: bool,
272
273 #[serde(default)]
275 pub fallback_chain: Vec<String>,
276
277 #[serde(default = "default_true")]
279 pub enable_fallback: bool,
280
281 #[serde(default)]
283 pub disable_fallback: bool,
284
285 #[serde(default = "default_circuit_failure_threshold")]
287 pub circuit_breaker_failure_threshold: u32,
288
289 #[serde(default = "default_circuit_open_duration_secs")]
291 pub circuit_breaker_open_duration_secs: u64,
292
293 #[serde(default)]
298 pub keybindings: HashMap<String, Vec<String>>,
299
300 #[serde(default)]
346 pub output_languages: HashMap<String, String>,
347
348 #[serde(default = "default_false")]
367 pub language_policy_enabled: bool,
368
369 #[serde(default)]
374 pub edit_format: EditFormat,
375
376 #[serde(default = "default_false")]
380 pub memory_enabled: bool,
381
382 #[serde(default)]
385 pub memory_db_path: Option<PathBuf>,
386
387 #[serde(default = "default_false")]
391 pub mnemopi_engine: bool,
392
393 #[serde(default)]
398 pub memory_backend: Option<String>,
399
400 #[serde(default = "default_false")]
406 pub memory_llm_extract: bool,
407
408 #[serde(default)]
412 pub memory_llm_extract_model: String,
413
414 #[serde(default = "default_embedding_provider")]
418 pub embedding_provider: String,
419
420 #[serde(default)]
422 pub embedding_base_url: Option<String>,
423
424 #[serde(default = "default_embedding_api_key_env")]
427 pub embedding_api_key_env: String,
428
429 #[serde(default = "default_embedding_model")]
432 pub embedding_model: String,
433
434 #[serde(default = "default_false")]
438 pub ttsr_enabled: bool,
439
440 #[serde(default = "default_ttsr_mode")]
442 pub ttsr_interrupt_mode: String,
443
444 #[serde(default)]
455 pub model_roles: HashMap<String, String>,
456 #[serde(default)]
460 pub advisor: AdvisorSettings,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct AdvisorSettings {
471 #[serde(default = "default_false")]
473 pub enabled: bool,
474 #[serde(default = "default_advisor_sync_backlog")]
478 pub sync_backlog: String,
479 #[serde(default)]
483 pub immune_turns: u64,
484}
485
486impl Default for AdvisorSettings {
487 fn default() -> Self {
488 Self {
489 enabled: false,
490 sync_backlog: default_advisor_sync_backlog(),
491 immune_turns: 0,
492 }
493 }
494}
495
496fn default_advisor_sync_backlog() -> String {
497 "off".to_string()
498}
499
500fn default_theme() -> String {
501 "default".to_string()
502}
503
504fn default_thinking_level() -> ThinkingLevel {
505 ThinkingLevel::Medium
506}
507
508fn default_session_history_size() -> usize {
509 100
510}
511
512fn default_true() -> bool {
513 true
514}
515
516fn default_false() -> bool {
517 false
518}
519
520fn default_ttsr_mode() -> String {
521 "prose_only".to_string()
522}
523
524fn default_circuit_failure_threshold() -> u32 {
525 5
526}
527
528fn default_circuit_open_duration_secs() -> u64 {
529 30
530}
531
532fn default_tool_timeout() -> u64 {
533 120
534}
535
536fn default_memory_backend() -> Option<String> {
537 None
538}
539
540fn default_embedding_provider() -> String {
541 "none".to_string()
542}
543
544fn default_embedding_model() -> String {
545 String::new()
546}
547
548fn default_embedding_api_key_env() -> String {
549 "OPENAI_API_KEY".to_string()
550}
551
552impl Default for Settings {
553 fn default() -> Self {
554 Self {
555 version: SETTINGS_VERSION,
556 thinking_level: ThinkingLevel::Medium,
557 theme: default_theme(),
558 glyph_set: GlyphSet::default(),
559 last_used_model: None,
560 last_used_provider: None,
561 default_model: None,
562 default_provider: None,
563 max_tokens: None,
564 temperature: None,
565 default_temperature: None,
566 max_response_tokens: None,
567 session_history_size: default_session_history_size(),
568 session_dir: None,
569 extensions_enabled: true,
570 auto_compaction: true,
571 disabled_tools: Vec::new(),
572 tool_timeout_seconds: default_tool_timeout(),
573 ask_timeout_secs: 0,
574 extensions: Vec::new(),
575 skills: Vec::new(),
576 prompts: Vec::new(),
577 themes: Vec::new(),
578 custom_providers: Vec::new(),
579 dynamic_models: HashMap::new(),
580 enable_routing: false,
582 router_profile: None,
583 prefer_cost_efficient: true,
584 fallback_chain: Vec::new(),
585 enable_fallback: true,
586 disable_fallback: false,
587 circuit_breaker_failure_threshold: 5,
588 circuit_breaker_open_duration_secs: 30,
589 keybindings: HashMap::new(),
590 output_languages: HashMap::new(),
591 language_policy_enabled: false,
592 edit_format: EditFormat::default(),
593 memory_enabled: false,
594 memory_db_path: None,
595 mnemopi_engine: false,
596 memory_backend: default_memory_backend(),
597 memory_llm_extract: false,
598 memory_llm_extract_model: String::new(),
599 embedding_provider: default_embedding_provider(),
600 embedding_base_url: None,
601 embedding_api_key_env: default_embedding_api_key_env(),
602 embedding_model: default_embedding_model(),
603 ttsr_enabled: false,
604 ttsr_interrupt_mode: default_ttsr_mode(),
605 model_roles: HashMap::new(),
606 advisor: AdvisorSettings::default(),
607 }
608 }
609}
610
611impl Settings {
612 pub fn settings_dir() -> Result<PathBuf> {
616 let base = dirs::home_dir().context("Cannot determine home directory")?;
617 Ok(base.join(".oxi"))
618 }
619
620 pub fn settings_toml_path() -> Result<PathBuf> {
622 Ok(Self::settings_dir()?.join("settings.toml"))
623 }
624
625 pub fn settings_json_path() -> Result<PathBuf> {
627 Ok(Self::settings_dir()?.join("settings.json"))
628 }
629
630 pub fn settings_path() -> Result<PathBuf> {
637 let json_path = Self::settings_json_path()?;
638 let toml_path = Self::settings_toml_path()?;
639
640 if json_path.exists() && toml_path.exists() {
641 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
643 return Ok(json_path);
644 }
645
646 if json_path.exists() {
647 return Ok(json_path);
648 }
649
650 if toml_path.exists() {
651 return Ok(toml_path);
652 }
653
654 Ok(json_path)
656 }
657
658 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
663 let json_path = Self::settings_json_path()?;
664 let toml_path = Self::settings_toml_path()?;
665
666 let (primary, secondary) = if prefer_json {
667 (&json_path, &toml_path)
668 } else {
669 (&toml_path, &json_path)
670 };
671
672 if primary.exists() {
673 return Ok(primary.clone());
674 }
675
676 if secondary.exists() {
677 return Ok(secondary.clone());
678 }
679
680 Ok(primary.clone())
682 }
683
684 pub fn detect_format(path: &Path) -> SettingsFormat {
686 match path.extension().and_then(|e| e.to_str()) {
687 Some("json") => SettingsFormat::Json,
688 Some("toml") => SettingsFormat::Toml,
689 _ => SettingsFormat::Json, }
691 }
692
693 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
698 let mut dir = start_dir.to_path_buf();
699 loop {
700 let json_candidate = dir.join(".oxi").join("settings.json");
702 if json_candidate.exists() {
703 return Some(json_candidate);
704 }
705
706 let toml_candidate = dir.join(".oxi").join("settings.toml");
707 if toml_candidate.exists() {
708 return Some(toml_candidate);
709 }
710
711 if !dir.pop() {
712 return None;
713 }
714 }
715 }
716
717 pub fn effective_session_dir(&self) -> Result<PathBuf> {
721 if let Some(ref dir) = self.session_dir {
722 return Ok(dir.clone());
723 }
724 Ok(Self::settings_dir()?.join("sessions"))
725 }
726
727 pub fn load() -> Result<Self> {
745 Self::load_from_cwd()
746 }
747
748 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
754 Self::load_from_with(dir, None)
755 }
756
757 pub fn load_from_with(
776 dir: &std::path::Path,
777 global_override: Option<&std::path::Path>,
778 ) -> Result<Self> {
779 let mut settings = Settings::default();
781
782 let resolved_global: Option<std::path::PathBuf> = match global_override {
785 Some(p) => Some(p.to_path_buf()),
786 None => Self::settings_path().ok(),
787 };
788 if let Some(ref gp) = resolved_global
789 && gp.exists()
790 {
791 settings = Self::layer_file(&settings, gp)?;
792 }
793
794 if let Some(project_path) = Self::find_project_settings(dir) {
796 settings = Self::layer_file(&settings, &project_path)?;
797 }
798
799 settings.apply_env();
801
802 settings = Self::migrate(settings)?;
804
805 settings.validate_output_languages();
807
808 Ok(settings)
809 }
810
811 fn validate_output_languages(&mut self) {
818 if self.output_languages.is_empty() {
819 return;
820 }
821 let known_langs: std::collections::HashSet<&str> =
822 KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
823
824 for (channel, lang) in &self.output_languages {
825 if !known_langs.contains(lang.as_str()) {
826 tracing::warn!(
827 "Unknown output_languages language code '{}' for channel '{}'. \
828 Keeping as-is (the model will likely understand).",
829 lang,
830 channel
831 );
832 }
833 }
834 }
835
836 pub fn load_from_cwd() -> Result<Self> {
838 let cwd = env::current_dir().context("Cannot determine current directory")?;
839 Self::load_from(&cwd)
840 }
841
842 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
848 let content = fs::read_to_string(path)
849 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
850
851 let format = Self::detect_format(path);
852 let overlay: serde_json::Value = match format {
853 SettingsFormat::Toml => {
854 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
855 format!("Failed to parse TOML settings from {}", path.display())
856 })?;
857 toml_value_to_json(toml_value)
859 }
860 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
861 format!("Failed to parse JSON settings from {}", path.display())
862 })?,
863 };
864
865 let base_json =
869 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
870
871 let merged = merge_json_values(base_json, overlay);
872 let result: Settings =
873 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
874
875 Ok(result)
876 }
877
878 #[allow(dead_code)]
903 pub fn apply_env(&mut self) {
904 }
908
909 #[allow(dead_code)]
915 pub fn from_env() -> Self {
916 Self::default()
917 }
918
919 pub fn save(&self) -> Result<()> {
926 let dir = Self::settings_dir()?;
927 let path = Self::settings_path()?;
928
929 if !dir.exists() {
930 fs::create_dir_all(&dir).with_context(|| {
931 format!("Failed to create settings directory {}", dir.display())
932 })?;
933 }
934
935 let format = Self::detect_format(&path);
936 let content = Self::serialize_for_format(self, format)?;
937
938 let tmp_path = path.with_extension("tmp");
940 fs::write(&tmp_path, &content)
941 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
942 fs::rename(&tmp_path, &path)
943 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
944
945 Ok(())
946 }
947
948 pub fn save_to(&self, path: &Path) -> Result<()> {
950 if let Some(parent) = path.parent()
951 && !parent.exists()
952 {
953 fs::create_dir_all(parent)
954 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
955 }
956
957 let format = Self::detect_format(path);
958 let content = Self::serialize_for_format(self, format)?;
959
960 let tmp_path = path.with_extension("tmp");
962 fs::write(&tmp_path, &content)
963 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
964 fs::rename(&tmp_path, path)
965 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
966
967 Ok(())
968 }
969
970 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
974 let dir = project_dir.join(".oxi");
975
976 if !dir.exists() {
977 fs::create_dir_all(&dir).with_context(|| {
978 format!(
979 "Failed to create project settings directory {}",
980 dir.display()
981 )
982 })?;
983 }
984
985 let json_path = dir.join("settings.json");
987 let toml_path = dir.join("settings.toml");
988
989 let path = if json_path.exists() {
990 &json_path
991 } else if toml_path.exists() {
992 &toml_path
993 } else {
994 &json_path
996 };
997
998 let format = Self::detect_format(path);
999 let content = Self::serialize_for_format(self, format)?;
1000
1001 let tmp_path = path.with_extension("tmp");
1003 fs::write(&tmp_path, &content)
1004 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
1005 fs::rename(&tmp_path, path)
1006 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
1007
1008 Ok(())
1009 }
1010
1011 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
1013 match format {
1014 SettingsFormat::Toml => {
1015 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
1016 }
1017 SettingsFormat::Json => serde_json::to_string_pretty(settings)
1018 .context("Failed to serialize settings to JSON"),
1019 }
1020 }
1021
1022 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
1024 match format {
1025 SettingsFormat::Toml => {
1026 toml::from_str(content).context("Failed to parse TOML settings")
1027 }
1028 SettingsFormat::Json => {
1029 serde_json::from_str(content).context("Failed to parse JSON settings")
1030 }
1031 }
1032 }
1033
1034 pub fn merge_cli(
1047 &mut self,
1048 model: Option<String>,
1049 provider: Option<String>,
1050 enable_routing: Option<bool>,
1051 prefer_cost_efficient: Option<bool>,
1052 fallback_chain: Option<Vec<String>>,
1053 disable_fallback: Option<bool>,
1054 ) {
1055 if let Some(m) = model {
1056 self.last_used_model = Some(m);
1057 }
1058 if let Some(p) = provider {
1059 self.last_used_provider = Some(p);
1060 }
1061 if let Some(r) = enable_routing {
1062 self.enable_routing = r;
1063 }
1064 if let Some(p) = prefer_cost_efficient {
1065 self.prefer_cost_efficient = p;
1066 }
1067 if let Some(fc) = fallback_chain
1068 && !fc.is_empty()
1069 {
1070 self.fallback_chain = fc;
1071 }
1072 if let Some(df) = disable_fallback {
1073 self.disable_fallback = df;
1074 if df {
1076 self.enable_fallback = false;
1077 }
1078 }
1079 }
1080
1081 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
1084 cli_model.map(String::from).or_else(|| {
1085 let model = self.last_used_model.as_ref()?;
1090 if model.contains('/') {
1091 Some(model.clone())
1093 } else if let Some(ref provider) = self.last_used_provider {
1094 Some(format!("{}/{}", provider, model))
1096 } else {
1097 Some(model.clone())
1098 }
1099 })
1100 }
1101
1102 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
1105 cli_provider
1106 .map(String::from)
1107 .or_else(|| self.last_used_provider.clone())
1108 }
1109
1110 pub fn effective_temperature(&self) -> Option<f64> {
1113 self.default_temperature
1114 .or(self.temperature.map(|t| t as f64))
1115 }
1116
1117 pub fn effective_max_tokens(&self) -> Option<usize> {
1120 self.max_response_tokens
1121 .or(self.max_tokens.map(|t| t as usize))
1122 }
1123
1124 pub fn router_profile(&self) -> Option<&str> {
1126 self.router_profile.as_deref()
1127 }
1128
1129 pub fn save_last_used(model_id: &str) {
1135 if let Ok(mut settings) = Self::load() {
1136 if let Some((provider, model)) = model_id.split_once('/') {
1137 settings.last_used_provider = Some(provider.to_string());
1138 settings.last_used_model = Some(model.to_string());
1139 } else {
1140 settings.last_used_model = Some(model_id.to_string());
1141 }
1142 let _ = settings.save();
1143 }
1144 }
1145
1146 pub fn save_theme(&mut self, name: &str) -> Result<()> {
1148 self.theme = name.to_string();
1149 self.save()
1150 }
1151
1152 pub fn get_theme_name(&self) -> String {
1154 if self.theme.is_empty() || self.theme == "default" {
1155 "oxi_dark".to_string()
1156 } else {
1157 self.theme.clone()
1158 }
1159 }
1160
1161 fn migrate(settings: Settings) -> Result<Settings> {
1177 let mut settings = settings;
1178
1179 match settings.version {
1180 SETTINGS_VERSION => {
1181 }
1183 0 => {
1184 if settings.tool_timeout_seconds == 0 {
1187 settings.tool_timeout_seconds = default_tool_timeout();
1188 }
1189 settings.version = SETTINGS_VERSION;
1190
1191 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1192 }
1193 1 | 2 => {
1194 settings.version = SETTINGS_VERSION;
1199 tracing::info!(
1200 "Migrated settings from version {} to {} (dynamic_models + output_languages + language_policy_enabled defaults applied)",
1201 settings.version,
1202 SETTINGS_VERSION
1203 );
1204 }
1205 3 => {
1206 if let Some(model) = settings.default_model.take() {
1208 if let Some((provider, model_name)) = model.split_once('/') {
1209 settings.last_used_provider = Some(provider.to_string());
1210 settings.last_used_model = Some(model_name.to_string());
1211 } else {
1212 settings.last_used_model = Some(model);
1213 }
1214 }
1215 settings.version = SETTINGS_VERSION;
1217 tracing::info!(
1218 "Migrated settings from version 3 to {} (default_model → last_used_model; output_languages + language_policy_enabled defaults)",
1219 SETTINGS_VERSION
1220 );
1221 }
1222 4 => {
1223 settings.version = SETTINGS_VERSION;
1227 tracing::info!(
1228 "Migrated settings from version 4 to {} (added output_languages + language_policy_enabled, both defaulted to off)",
1229 SETTINGS_VERSION
1230 );
1231 }
1232 5 => {
1233 settings.version = SETTINGS_VERSION;
1239 tracing::info!(
1240 "Migrated settings from version 5 to {} (added language_policy_enabled, defaulting to OFF — toggle ON in /settings to activate existing channels)",
1241 SETTINGS_VERSION
1242 );
1243 }
1244 6 => {
1245 settings.version = SETTINGS_VERSION;
1248 tracing::info!(
1249 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1250 SETTINGS_VERSION
1251 );
1252 }
1253 7 => {
1254 settings.version = SETTINGS_VERSION;
1257 tracing::info!(
1258 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1259 SETTINGS_VERSION
1260 );
1261 }
1262 8 => {
1263 settings.version = SETTINGS_VERSION;
1266 tracing::info!(
1267 "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1268 SETTINGS_VERSION
1269 );
1270 }
1271 v if v > SETTINGS_VERSION => {
1272 anyhow::bail!(
1274 "Settings version {} is newer than supported version {}. \
1275 Please update oxi.",
1276 v,
1277 SETTINGS_VERSION
1278 );
1279 }
1280 v => {
1281 tracing::warn!(
1283 "Unknown settings version {}, attempting migration to {}",
1284 v,
1285 SETTINGS_VERSION
1286 );
1287 settings.version = SETTINGS_VERSION;
1288 }
1289 }
1290
1291 Ok(settings)
1292 }
1293}
1294
1295#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1299pub enum SettingsFormat {
1300 #[default]
1302 Json,
1303 Toml,
1305}
1306
1307impl SettingsFormat {
1308 pub fn extension(&self) -> &'static str {
1310 match self {
1311 SettingsFormat::Json => "json",
1312 SettingsFormat::Toml => "toml",
1313 }
1314 }
1315}
1316
1317fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1321 match toml {
1322 toml::Value::String(s) => serde_json::Value::String(s),
1323 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1324 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1325 .map(serde_json::Value::Number)
1326 .unwrap_or(serde_json::Value::Null),
1327 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1328 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1329 toml::Value::Array(arr) => {
1330 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1331 }
1332 toml::Value::Table(table) => {
1333 let obj = table
1334 .into_iter()
1335 .map(|(k, v)| (k, toml_value_to_json(v)))
1336 .collect();
1337 serde_json::Value::Object(obj)
1338 }
1339 }
1340}
1341
1342fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1344 match (base, override_) {
1345 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1347 let mut result = base_map;
1348 for (key, override_value) in override_map {
1349 let base_value = result.remove(&key);
1350 let merged = match base_value {
1351 Some(base_v) => merge_json_values(base_v, override_value),
1352 None => override_value,
1353 };
1354 result.insert(key, merged);
1355 }
1356 serde_json::Value::Object(result)
1357 }
1358 (_, override_) => override_,
1360 }
1361}
1362
1363pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1365 match s.to_lowercase().as_str() {
1366 "off" | "none" => Some(ThinkingLevel::Off),
1367 "minimal" => Some(ThinkingLevel::Minimal),
1368 "low" => Some(ThinkingLevel::Low),
1369 "medium" | "standard" => Some(ThinkingLevel::Medium),
1370 "high" | "thorough" => Some(ThinkingLevel::High),
1371 "xhigh" => Some(ThinkingLevel::XHigh),
1372 _ => None,
1373 }
1374}
1375
1376#[allow(dead_code)]
1378fn parse_boolish(s: &str) -> Result<bool> {
1379 match s.to_lowercase().as_str() {
1380 "true" | "1" | "yes" | "on" => Ok(true),
1381 "false" | "0" | "no" | "off" => Ok(false),
1382 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1383 }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388 use super::*;
1389 use std::io::Write as IoWrite;
1390 use std::sync::Mutex;
1391
1392 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1395
1396 struct EnvGuard {
1399 saved: Vec<(String, Option<String>)>,
1400 }
1401
1402 impl EnvGuard {
1403 fn new(vars: &[&str]) -> Self {
1404 let saved = vars
1405 .iter()
1406 .map(|&name| {
1407 let old = env::var(name).ok();
1408 unsafe { env::remove_var(name) };
1410 (name.to_string(), old)
1411 })
1412 .collect();
1413 Self { saved }
1414 }
1415 }
1416
1417 impl Drop for EnvGuard {
1418 fn drop(&mut self) {
1419 for (name, old) in self.saved.drain(..) {
1420 match old {
1421 Some(val) => unsafe { env::set_var(&name, val) },
1423 None => unsafe { env::remove_var(&name) },
1424 }
1425 }
1426 }
1427 }
1428
1429 #[test]
1432 fn test_default_settings() {
1433 let settings = Settings::default();
1434 assert_eq!(settings.version, SETTINGS_VERSION);
1435 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1436 assert_eq!(settings.theme, "default");
1437 assert!(settings.last_used_model.is_none());
1438 assert!(settings.last_used_provider.is_none());
1439 assert!(settings.extensions_enabled);
1440 assert!(settings.auto_compaction);
1441 assert_eq!(settings.tool_timeout_seconds, 120);
1442 }
1443
1444 #[test]
1445 fn test_merge_cli() {
1446 let mut settings = Settings::default();
1447 settings.last_used_model = Some("gpt-4o".to_string());
1448
1449 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1450 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1451
1452 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1453 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1454
1455 settings.merge_cli(
1457 None,
1458 None,
1459 Some(true),
1460 Some(false),
1461 Some(vec!["openai/gpt-4o".to_string()]),
1462 Some(false),
1463 );
1464 assert!(settings.enable_routing);
1465 assert!(!settings.prefer_cost_efficient);
1466 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1467 assert!(!settings.disable_fallback);
1468
1469 let mut settings2 = Settings::default();
1471 settings2.merge_cli(None, None, None, None, None, Some(true));
1472 assert!(settings2.disable_fallback);
1473 assert!(!settings2.enable_fallback);
1474 }
1475
1476 #[test]
1479 fn test_layer_file_overrides() {
1480 let base = Settings::default();
1481
1482 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1483 let toml_content = r#"
1484last_used_model = "openai/gpt-4o"
1485theme = "dracula"
1486"#;
1487 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1488
1489 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1490 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1491 assert_eq!(merged.theme, "dracula");
1492 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1494 assert!(merged.extensions_enabled);
1495 }
1496
1497 #[test]
1498 fn test_layer_file_preserves_unset() {
1499 let mut base = Settings::default();
1500 base.last_used_provider = Some("deepseek".to_string());
1501
1502 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1503 let toml_content = "theme = \"monokai\"\n";
1505 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1506
1507 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1508 assert_eq!(merged.theme, "monokai");
1509 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1510 }
1511
1512 #[test]
1513 fn test_load_from_dir_with_project_config() {
1514 let _guard = EnvGuard::new(&[
1515 "OXI_MODEL",
1516 "OXI_PROVIDER",
1517 "OXI_THEME",
1518 "OXI_TOOL_TIMEOUT",
1519 "OXI_TEMPERATURE",
1520 "OXI_MAX_TOKENS",
1521 "OXI_SESSION_DIR",
1522 "OXI_EXTENSIONS_ENABLED",
1523 ]);
1524 let tmp = tempfile::tempdir().unwrap();
1525 let oxi_dir = tmp.path().join(".oxi");
1526 fs::create_dir_all(&oxi_dir).unwrap();
1527 let settings_path = oxi_dir.join("settings.toml");
1528 fs::write(
1530 &settings_path,
1531 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1532 )
1533 .unwrap();
1534
1535 let settings = Settings::load_from(tmp.path()).unwrap();
1536 assert_eq!(
1538 settings.last_used_model,
1539 Some("gemini-2.0-flash".to_string())
1540 );
1541 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1542 }
1543
1544 #[test]
1545 fn test_load_from_dir_no_config() {
1546 let _guard = EnvGuard::new(&[
1548 "OXI_MODEL",
1549 "OXI_PROVIDER",
1550 "OXI_THEME",
1551 "OXI_TOOL_TIMEOUT",
1552 "OXI_TEMPERATURE",
1553 "OXI_MAX_TOKENS",
1554 "OXI_SESSION_DIR",
1555 "OXI_EXTENSIONS_ENABLED",
1556 ]);
1557 let tmp = tempfile::tempdir().unwrap();
1558 let global = tmp.path().join("nonexistent-settings.json");
1563 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1564 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1565 }
1566 #[test]
1567 fn test_from_env() {
1568 let _guard = EnvGuard::new(&[
1571 "OXI_MODEL",
1573 "OXI_THEME",
1574 "OXI_TOOL_TIMEOUT",
1575 "OXI_PROVIDER",
1576 "OXI_DEFAULT_MODEL",
1577 ]);
1578
1579 let settings = Settings::from_env();
1580 assert_eq!(settings.last_used_model, None);
1582 assert_eq!(settings.theme, "default");
1583 assert_eq!(settings.tool_timeout_seconds, 120);
1584 }
1585
1586 #[test]
1587 fn test_apply_env_boolish() {
1588 let _guard = EnvGuard::new(&["OXI_EXTENSIONS_ENABLED"]);
1591 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1592
1593 let mut settings = Settings::default();
1594 settings.apply_env();
1595 assert!(settings.extensions_enabled); }
1598
1599 #[test]
1600 fn test_apply_env_temperature() {
1601 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1603 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1604
1605 let mut settings = Settings::default();
1606 settings.apply_env();
1607 assert_eq!(settings.default_temperature, None);
1609 }
1610
1611 #[test]
1612 fn test_env_does_not_override_when_unset() {
1613 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1614 let settings = Settings::from_env();
1615 assert!(settings.last_used_model.is_none());
1616 assert!(settings.last_used_provider.is_none());
1617 }
1618
1619 #[test]
1620 fn test_parse_thinking_level() {
1621 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1622 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1623 assert_eq!(
1624 parse_thinking_level("MINIMAL"),
1625 Some(ThinkingLevel::Minimal)
1626 );
1627 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1628 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1629 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1630 assert_eq!(
1631 parse_thinking_level("Standard"),
1632 Some(ThinkingLevel::Medium)
1633 );
1634 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1635 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1636 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1637 assert_eq!(parse_thinking_level("invalid"), None);
1638 }
1639
1640 #[test]
1641 fn test_parse_boolish() {
1642 assert!(parse_boolish("true").unwrap());
1643 assert!(parse_boolish("1").unwrap());
1644 assert!(parse_boolish("yes").unwrap());
1645 assert!(parse_boolish("ON").unwrap());
1646 assert!(!parse_boolish("false").unwrap());
1647 assert!(!parse_boolish("0").unwrap());
1648 assert!(!parse_boolish("no").unwrap());
1649 assert!(!parse_boolish("OFF").unwrap());
1650 assert!(parse_boolish("maybe").is_err());
1651 }
1652
1653 #[test]
1656 fn test_effective_model_returns_last_used() {
1657 let mut settings = Settings::default();
1658 settings.last_used_model = Some("openai/gpt-4o".to_string());
1659 assert_eq!(
1660 settings.effective_model(None),
1661 Some("openai/gpt-4o".to_string())
1662 );
1663 }
1664
1665 #[test]
1666 fn test_effective_model_cli_overrides() {
1667 let mut settings = Settings::default();
1668 settings.last_used_model = Some("openai/gpt-4o".to_string());
1669 assert_eq!(
1670 settings.effective_model(Some("anthropic/claude-3")),
1671 Some("anthropic/claude-3".to_string())
1672 );
1673 }
1674
1675 #[test]
1676 fn test_effective_model_none_when_unset() {
1677 let settings = Settings::default();
1678 assert_eq!(settings.effective_model(None), None);
1679 }
1680
1681 #[test]
1682 fn test_effective_model_falls_back_to_last_used() {
1683 let mut settings = Settings::default();
1684 settings.last_used_model = Some("anthropic/claude-3".to_string());
1685 assert_eq!(
1686 settings.effective_model(None),
1687 Some("anthropic/claude-3".to_string())
1688 );
1689 }
1690
1691 #[test]
1692 fn test_effective_model_returns_none_when_nothing_set() {
1693 let settings = Settings::default();
1694 assert_eq!(settings.effective_model(None), None);
1695 }
1696
1697 #[test]
1698 fn test_effective_temperature_prefers_f64() {
1699 let mut settings = Settings::default();
1700 settings.temperature = Some(0.5);
1701 settings.default_temperature = Some(0.7);
1702 assert_eq!(settings.effective_temperature(), Some(0.7));
1703 }
1704
1705 #[test]
1706 fn test_effective_temperature_falls_back_to_f32() {
1707 let mut settings = Settings::default();
1708 settings.temperature = Some(0.5);
1709 assert_eq!(settings.effective_temperature(), Some(0.5));
1710 }
1711
1712 #[test]
1713 fn test_effective_max_tokens_prefers_usize() {
1714 let mut settings = Settings::default();
1715 settings.max_tokens = Some(1024);
1716 settings.max_response_tokens = Some(4096);
1717 assert_eq!(settings.effective_max_tokens(), Some(4096));
1718 }
1719
1720 #[test]
1721 fn test_effective_max_tokens_falls_back_to_u32() {
1722 let mut settings = Settings::default();
1723 settings.max_tokens = Some(1024);
1724 assert_eq!(settings.effective_max_tokens(), Some(1024));
1725 }
1726
1727 #[test]
1730 fn test_effective_session_dir_default() {
1731 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1732 let settings = Settings::default();
1733 let dir = settings.effective_session_dir().unwrap();
1734 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1735 }
1736
1737 #[test]
1738 fn test_effective_session_dir_from_field() {
1739 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1740 let mut settings = Settings::default();
1741 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1742 assert_eq!(
1743 settings.effective_session_dir().unwrap(),
1744 PathBuf::from("/tmp/oxi-sessions")
1745 );
1746 }
1747
1748 #[test]
1749 fn test_effective_session_dir_env_disabled() {
1750 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1753 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1754 let settings = Settings::default();
1755 let dir = settings.effective_session_dir().unwrap();
1757 assert!(
1758 dir.ends_with("sessions"),
1759 "expected default sessions dir, got: {:?}",
1760 dir
1761 );
1762 }
1763
1764 #[test]
1767 fn test_migration_v0_to_v1() {
1768 let mut settings = Settings::default();
1769 settings.version = 0;
1770 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1773 assert_eq!(migrated.version, SETTINGS_VERSION);
1774 assert_eq!(migrated.tool_timeout_seconds, 120);
1775 }
1776
1777 #[test]
1778 fn test_migration_already_current() {
1779 let settings = Settings::default();
1780 let migrated = Settings::migrate(settings).unwrap();
1781 assert_eq!(migrated.version, SETTINGS_VERSION);
1782 }
1783
1784 #[test]
1785 fn test_migration_v3_to_v4_splits_model() {
1786 let mut settings = Settings::default();
1787 settings.version = 3;
1788 settings.default_model = Some("openai/gpt-4o".to_string());
1789 settings.default_provider = None;
1790
1791 let migrated = Settings::migrate(settings).unwrap();
1792 assert_eq!(migrated.version, SETTINGS_VERSION);
1793 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1794 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1795 }
1796
1797 #[test]
1798 fn test_migration_v3_no_slash_keeps_model() {
1799 let mut settings = Settings::default();
1800 settings.version = 3;
1801 settings.default_model = Some("bare-model-name".to_string());
1802
1803 let migrated = Settings::migrate(settings).unwrap();
1804 assert_eq!(migrated.version, SETTINGS_VERSION);
1805 assert_eq!(
1806 migrated.last_used_model,
1807 Some("bare-model-name".to_string())
1808 );
1809 }
1810
1811 #[test]
1812 fn test_migration_future_version_fails() {
1813 let mut settings = Settings::default();
1814 settings.version = 9999;
1815 assert!(Settings::migrate(settings).is_err());
1816 }
1817
1818 #[test]
1821 fn test_default_output_languages_is_empty() {
1822 let settings = Settings::default();
1823 assert!(
1824 settings.output_languages.is_empty(),
1825 "all channels should default to auto (empty map)"
1826 );
1827 }
1828
1829 #[test]
1830 fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1831 let mut settings = Settings::default();
1832 settings.version = 4;
1833 settings
1834 .output_languages
1835 .insert("response".to_string(), "ko".to_string());
1836 settings
1837 .output_languages
1838 .insert("commit_message".to_string(), "en".to_string());
1839
1840 let migrated = Settings::migrate(settings).unwrap();
1841 assert_eq!(migrated.version, SETTINGS_VERSION);
1842 assert_eq!(
1843 migrated.output_languages.get("response"),
1844 Some(&"ko".to_string())
1845 );
1846 assert_eq!(
1847 migrated.output_languages.get("commit_message"),
1848 Some(&"en".to_string())
1849 );
1850 }
1851
1852 #[test]
1853 fn test_migration_v4_to_v5_creates_empty_if_missing() {
1854 let mut settings = Settings::default();
1858 settings.version = 4;
1859 assert!(settings.output_languages.is_empty());
1860
1861 let migrated = Settings::migrate(settings).unwrap();
1862 assert_eq!(migrated.version, SETTINGS_VERSION);
1863 assert!(migrated.output_languages.is_empty());
1864 }
1865
1866 #[test]
1867 fn test_validate_keeps_user_defined_channel() {
1868 let mut settings = Settings::default();
1873 settings
1874 .output_languages
1875 .insert("pr_description".to_string(), "en".to_string()); settings
1877 .output_languages
1878 .insert("response".to_string(), "ko".to_string()); settings.validate_output_languages();
1881
1882 assert!(settings.output_languages.contains_key("pr_description"));
1883 assert!(settings.output_languages.contains_key("response"));
1884 assert_eq!(
1885 settings.output_languages.get("pr_description"),
1886 Some(&"en".to_string())
1887 );
1888 assert_eq!(
1889 settings.output_languages.get("response"),
1890 Some(&"ko".to_string())
1891 );
1892 }
1893
1894 #[test]
1895 fn test_validate_keeps_unknown_lang_with_warning() {
1896 let mut settings = Settings::default();
1897 settings
1898 .output_languages
1899 .insert("response".to_string(), "klingon".to_string()); settings
1901 .output_languages
1902 .insert("commit_message".to_string(), "en".to_string()); settings.validate_output_languages();
1905
1906 assert_eq!(
1909 settings.output_languages.get("response"),
1910 Some(&"klingon".to_string())
1911 );
1912 assert_eq!(
1913 settings.output_languages.get("commit_message"),
1914 Some(&"en".to_string())
1915 );
1916 }
1917
1918 #[test]
1919 fn test_known_channels_table_includes_core_four() {
1920 let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1921 assert!(keys.contains(&"response"));
1922 assert!(keys.contains(&"code_comment"));
1923 assert!(keys.contains(&"documentation"));
1924 assert!(keys.contains(&"commit_message"));
1925 }
1926
1927 #[test]
1928 fn test_known_langs_table_includes_auto_and_english() {
1929 let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1930 assert!(codes.contains(&"auto"));
1931 assert!(codes.contains(&"en"));
1932 }
1933
1934 #[test]
1935 fn test_default_language_policy_enabled_is_false() {
1936 let settings = Settings::default();
1938 assert!(
1939 !settings.language_policy_enabled,
1940 "language_policy_enabled must default to false (opt-in)"
1941 );
1942 }
1943
1944 #[test]
1945 fn test_migration_v5_to_v6_defaults_master_toggle_to_off() {
1946 let mut settings = Settings::default();
1950 settings.version = 5;
1951 settings
1952 .output_languages
1953 .insert("response".to_string(), "ko".to_string());
1954 settings
1955 .output_languages
1956 .insert("commit_message".to_string(), "en".to_string());
1957
1958 let migrated = Settings::migrate(settings).unwrap();
1959 assert_eq!(migrated.version, SETTINGS_VERSION);
1960 assert!(
1961 !migrated.language_policy_enabled,
1962 "v5 → v6 migration must default language_policy_enabled to false"
1963 );
1964 assert_eq!(
1966 migrated.output_languages.get("response"),
1967 Some(&"ko".to_string())
1968 );
1969 assert_eq!(
1970 migrated.output_languages.get("commit_message"),
1971 Some(&"en".to_string())
1972 );
1973 }
1974
1975 #[test]
1976 fn test_default_glyph_set_is_unicode() {
1977 let settings = Settings::default();
1978 assert_eq!(
1979 settings.glyph_set,
1980 GlyphSet::Unicode,
1981 "glyph_set must default to Unicode"
1982 );
1983 }
1984
1985 #[test]
1986 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1987 let mut settings = Settings::default();
1990 settings.version = 7;
1991 settings.glyph_set = GlyphSet::default();
1993
1994 let migrated = Settings::migrate(settings).unwrap();
1995 assert_eq!(migrated.version, SETTINGS_VERSION);
1996 assert_eq!(
1997 migrated.glyph_set,
1998 GlyphSet::Unicode,
1999 "v7 → v8 migration must default glyph_set to unicode"
2000 );
2001 }
2002
2003 #[test]
2004 fn test_glyph_set_persists_through_roundtrip() {
2005 let mut original = Settings::default();
2009 original.glyph_set = GlyphSet::Nerd;
2010 let content = toml::to_string_pretty(&original).unwrap();
2011 assert!(
2012 content.contains("glyph_set = \"nerd\""),
2013 "nerd preset must serialize to snake_case; got:\n{content}"
2014 );
2015 let loaded: Settings = toml::from_str(&content).unwrap();
2016 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
2017 original.glyph_set = GlyphSet::Unicode;
2019 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
2020 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
2021 }
2022
2023 #[test]
2024 fn test_save_and_load_roundtrip_preserves_language_policy_enabled() {
2025 let tmp = tempfile::tempdir().unwrap();
2026 let settings_path = tmp.path().join("settings.toml");
2027
2028 let mut original = Settings::default();
2029 original.language_policy_enabled = true;
2030 original
2031 .output_languages
2032 .insert("response".to_string(), "ko".to_string());
2033
2034 let content = toml::to_string_pretty(&original).unwrap();
2035 fs::write(&settings_path, &content).unwrap();
2036
2037 let loaded_content = fs::read_to_string(&settings_path).unwrap();
2038 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2039
2040 assert!(loaded.language_policy_enabled);
2041 assert_eq!(
2042 loaded.output_languages.get("response"),
2043 Some(&"ko".to_string())
2044 );
2045 }
2046
2047 #[test]
2048 fn test_save_and_load_roundtrip_preserves_output_languages() {
2049 let tmp = tempfile::tempdir().unwrap();
2050 let settings_path = tmp.path().join("settings.toml");
2051
2052 let mut original = Settings::default();
2053 original
2054 .output_languages
2055 .insert("response".to_string(), "ko".to_string());
2056 original
2057 .output_languages
2058 .insert("commit_message".to_string(), "en".to_string());
2059
2060 let content = toml::to_string_pretty(&original).unwrap();
2061 fs::write(&settings_path, &content).unwrap();
2062
2063 let loaded_content = fs::read_to_string(&settings_path).unwrap();
2064 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2065
2066 assert_eq!(
2067 loaded.output_languages.get("response"),
2068 Some(&"ko".to_string())
2069 );
2070 assert_eq!(
2071 loaded.output_languages.get("commit_message"),
2072 Some(&"en".to_string())
2073 );
2074 }
2075
2076 #[test]
2079 fn test_save_and_load_roundtrip() {
2080 let tmp = tempfile::tempdir().unwrap();
2081 let settings_path = tmp.path().join("settings.toml");
2082
2083 let mut original = Settings::default();
2084 original.last_used_model = Some("gpt-4o".to_string());
2085 original.last_used_provider = Some("openai".to_string());
2086 original.theme = "dracula".to_string();
2087 original.tool_timeout_seconds = 60;
2088
2089 let content = toml::to_string_pretty(&original).unwrap();
2091 fs::write(&settings_path, &content).unwrap();
2092
2093 let loaded_content = fs::read_to_string(&settings_path).unwrap();
2095 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2096
2097 assert_eq!(loaded.last_used_model, original.last_used_model);
2098 assert_eq!(loaded.theme, original.theme);
2099 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
2100 }
2101
2102 #[test]
2103 fn test_toml_roundtrip_preserves_new_fields() {
2104 let mut settings = Settings::default();
2105 settings.default_temperature = Some(0.8);
2106 settings.max_response_tokens = Some(8192);
2107 settings.auto_compaction = false;
2108 settings.extensions_enabled = false;
2109 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
2110
2111 let toml_str = toml::to_string_pretty(&settings).unwrap();
2112 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2113
2114 assert_eq!(parsed.default_temperature, Some(0.8));
2115 assert_eq!(parsed.max_response_tokens, Some(8192));
2116 assert!(!parsed.auto_compaction);
2117 assert!(!parsed.extensions_enabled);
2118 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
2119 }
2120
2121 #[test]
2124 fn test_json_roundtrip() {
2125 let mut settings = Settings::default();
2126 settings.last_used_model = Some("gpt-4o".to_string());
2127 settings.last_used_provider = Some("openai".to_string());
2128 settings.theme = "dracula".to_string();
2129 settings.tool_timeout_seconds = 60;
2130 settings.default_temperature = Some(0.8);
2131 settings.max_response_tokens = Some(8192);
2132
2133 let json_str = serde_json::to_string_pretty(&settings).unwrap();
2134 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
2135
2136 assert_eq!(parsed.last_used_model, settings.last_used_model);
2137 assert_eq!(parsed.theme, settings.theme);
2138 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
2139 assert_eq!(parsed.default_temperature, settings.default_temperature);
2140 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
2141 }
2142
2143 #[test]
2144 fn test_json_serialize_for_format() {
2145 let mut settings = Settings::default();
2146 settings.last_used_model = Some("claude-3".to_string());
2147 settings.last_used_provider = Some("anthropic".to_string());
2148 settings.thinking_level = ThinkingLevel::Minimal;
2149
2150 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
2151 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
2152
2153 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
2154 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
2155 }
2156
2157 #[test]
2158 fn test_toml_serialize_for_format() {
2159 let mut settings = Settings::default();
2160 settings.last_used_model = Some("gemini-pro".to_string());
2161 settings.last_used_provider = Some("google".to_string());
2162 settings.thinking_level = ThinkingLevel::High;
2163
2164 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
2165 let parsed: Settings = toml::from_str(&toml_content).unwrap();
2166
2167 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2168 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
2169 }
2170
2171 #[test]
2172 fn test_parse_from_str_json() {
2173 let json_content = r#"{
2174 "last_used_model": "gpt-4",
2175 "last_used_provider": "openai",
2176 "theme": "nord",
2177 "tool_timeout_seconds": 90
2178 }"#;
2179
2180 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
2181 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
2182 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
2183 assert_eq!(settings.theme, "nord");
2184 assert_eq!(settings.tool_timeout_seconds, 90);
2185 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2187 assert!(settings.extensions_enabled);
2188 }
2189
2190 #[test]
2191 fn test_parse_from_str_toml() {
2192 let toml_content = r#"
2193last_used_model = "claude-opus"
2194last_used_provider = "anthropic"
2195theme = "monokai"
2196tool_timeout_seconds = 45
2197"#;
2198
2199 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
2200 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
2201 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
2202 assert_eq!(settings.theme, "monokai");
2203 assert_eq!(settings.tool_timeout_seconds, 45);
2204 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2205 }
2206
2207 #[test]
2208 fn test_layer_file_json() {
2209 let base = Settings::default();
2210
2211 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2212 let json_content = r#"{
2213 "last_used_model": "gpt-4o",
2214 "last_used_provider": "openai",
2215 "theme": "dracula",
2216 "auto_compaction": false
2217 }"#;
2218 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2219
2220 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2221 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
2222 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
2223 assert_eq!(merged.theme, "dracula");
2224 assert!(!merged.auto_compaction);
2225 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
2227 assert!(merged.extensions_enabled);
2228 assert_eq!(merged.tool_timeout_seconds, 120);
2229 }
2230
2231 #[test]
2232 fn test_layer_file_json_preserves_unset() {
2233 let mut base = Settings::default();
2234 base.last_used_provider = Some("deepseek".to_string());
2235
2236 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2237 let json_content = r#"{ "theme": "nord" }"#;
2238 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2239
2240 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2241 assert_eq!(merged.theme, "nord");
2242 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
2243 }
2244
2245 #[test]
2246 fn test_save_to_json() {
2247 let tmp = tempfile::tempdir().unwrap();
2248 let settings_path = tmp.path().join("settings.json");
2249
2250 let mut settings = Settings::default();
2251 settings.last_used_model = Some("gpt-4o".to_string());
2252 settings.last_used_provider = Some("openai".to_string());
2253 settings.theme = "dracula".to_string();
2254 settings.tool_timeout_seconds = 60;
2255
2256 settings.save_to(&settings_path).unwrap();
2257
2258 let content = fs::read_to_string(&settings_path).unwrap();
2260 let parsed: Settings = serde_json::from_str(&content).unwrap();
2261 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
2262 assert_eq!(parsed.theme, "dracula");
2263 assert_eq!(parsed.tool_timeout_seconds, 60);
2264 }
2265
2266 #[test]
2267 fn test_save_to_toml() {
2268 let tmp = tempfile::tempdir().unwrap();
2269 let settings_path = tmp.path().join("settings.toml");
2270
2271 let mut settings = Settings::default();
2272 settings.last_used_model = Some("gemini-pro".to_string());
2273 settings.last_used_provider = Some("google".to_string());
2274 settings.theme = "monokai".to_string();
2275 settings.tool_timeout_seconds = 90;
2276
2277 settings.save_to(&settings_path).unwrap();
2278
2279 let content = fs::read_to_string(&settings_path).unwrap();
2281 let parsed: Settings = toml::from_str(&content).unwrap();
2282 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2283 assert_eq!(parsed.theme, "monokai");
2284 assert_eq!(parsed.tool_timeout_seconds, 90);
2285 }
2286
2287 #[test]
2288 fn test_load_from_dir_with_json_project_config() {
2289 let _guard = EnvGuard::new(&[
2290 "OXI_MODEL",
2291 "OXI_PROVIDER",
2292 "OXI_THEME",
2293 "OXI_TOOL_TIMEOUT",
2294 "OXI_TEMPERATURE",
2295 "OXI_MAX_TOKENS",
2296 "OXI_SESSION_DIR",
2297 "OXI_EXTENSIONS_ENABLED",
2298 ]);
2299 let tmp = tempfile::tempdir().unwrap();
2300 let oxi_dir = tmp.path().join(".oxi");
2301 fs::create_dir_all(&oxi_dir).unwrap();
2302 let settings_path = oxi_dir.join("settings.json");
2303 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
2305 fs::write(&settings_path, json_content).unwrap();
2306
2307 let settings = Settings::load_from(tmp.path()).unwrap();
2308 assert_eq!(
2310 settings.last_used_model,
2311 Some("gemini-2.0-flash".to_string())
2312 );
2313 assert_eq!(settings.last_used_provider, Some("google".to_string()));
2314 }
2315
2316 #[test]
2317 fn test_find_project_settings_json_priority() {
2318 let tmp = tempfile::tempdir().unwrap();
2319 let oxi_dir = tmp.path().join(".oxi");
2320 fs::create_dir_all(&oxi_dir).unwrap();
2321
2322 let json_path = oxi_dir.join("settings.json");
2324 let toml_path = oxi_dir.join("settings.toml");
2325 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2326 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2327
2328 let found = Settings::find_project_settings(tmp.path());
2330 assert!(found.is_some());
2331 assert_eq!(
2332 found.unwrap().file_name().unwrap().to_str().unwrap(),
2333 "settings.json"
2334 );
2335 }
2336
2337 #[test]
2338 fn test_find_project_settings_json_only() {
2339 let tmp = tempfile::tempdir().unwrap();
2340 let oxi_dir = tmp.path().join(".oxi");
2341 fs::create_dir_all(&oxi_dir).unwrap();
2342
2343 let json_path = oxi_dir.join("settings.json");
2344 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2345
2346 let found = Settings::find_project_settings(tmp.path());
2347 assert!(found.is_some());
2348 assert_eq!(
2349 found.unwrap().file_name().unwrap().to_str().unwrap(),
2350 "settings.json"
2351 );
2352 }
2353
2354 #[test]
2355 fn test_find_project_settings_toml_fallback() {
2356 let tmp = tempfile::tempdir().unwrap();
2357 let oxi_dir = tmp.path().join(".oxi");
2358 fs::create_dir_all(&oxi_dir).unwrap();
2359
2360 let toml_path = oxi_dir.join("settings.toml");
2361 fs::write(&toml_path, r#"theme = "test""#).unwrap();
2362
2363 let found = Settings::find_project_settings(tmp.path());
2364 assert!(found.is_some());
2365 assert_eq!(
2366 found.unwrap().file_name().unwrap().to_str().unwrap(),
2367 "settings.toml"
2368 );
2369 }
2370
2371 #[test]
2372 fn test_detect_format() {
2373 let json_path = PathBuf::from("/test/settings.json");
2374 let toml_path = PathBuf::from("/test/settings.toml");
2375 let unknown_path = PathBuf::from("/test/settings");
2376
2377 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2378 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2379 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2380 }
2382
2383 #[test]
2384 fn test_settings_format_extension() {
2385 assert_eq!(SettingsFormat::Json.extension(), "json");
2386 assert_eq!(SettingsFormat::Toml.extension(), "toml");
2387 }
2388
2389 #[test]
2390 fn test_layer_json_over_toml() {
2391 let tmp = tempfile::tempdir().unwrap();
2393 let oxi_dir = tmp.path().join(".oxi");
2394 fs::create_dir_all(&oxi_dir).unwrap();
2395
2396 let json_path = oxi_dir.join("settings.json");
2397 let toml_path = oxi_dir.join("settings.toml");
2398
2399 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2401 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2403
2404 let settings = Settings::load_from(tmp.path()).unwrap();
2406 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2407 }
2408
2409 #[test]
2410 fn test_mixed_format_loading() {
2411 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2413 let toml_content = r#"
2414last_used_model = "loaded-via-toml"
2415theme = "loaded-theme"
2416"#;
2417 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2418
2419 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2420 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2421 assert_eq!(merged.theme, "loaded-theme");
2422 }
2423
2424 #[test]
2425 fn test_merge_json_values() {
2426 let base = serde_json::json!({
2427 "version": 1,
2428 "theme": "default",
2429 "extensions": ["ext1"],
2430 "nested": {
2431 "a": 1,
2432 "b": 2
2433 }
2434 });
2435
2436 let override_ = serde_json::json!({
2437 "version": 2,
2438 "theme": "dark",
2439 "extensions": ["ext2"],
2440 "nested": {
2441 "b": 20,
2442 "c": 30
2443 }
2444 });
2445
2446 let merged = merge_json_values(base, override_);
2447
2448 assert_eq!(merged["version"], 2);
2449 assert_eq!(merged["theme"], "dark");
2450 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2452 assert_eq!(merged["nested"]["a"], 1);
2454 assert_eq!(merged["nested"]["b"], 20);
2455 assert_eq!(merged["nested"]["c"], 30);
2456 }
2457
2458 #[test]
2459 fn test_save_project_preserves_existing_format() {
2460 let tmp = tempfile::tempdir().unwrap();
2461 let oxi_dir = tmp.path().join(".oxi");
2462 fs::create_dir_all(&oxi_dir).unwrap();
2463
2464 let toml_path = oxi_dir.join("settings.toml");
2466 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2467
2468 let mut settings = Settings::default();
2469 settings.theme = "new-theme".to_string();
2470 settings.save_project(tmp.path()).unwrap();
2471
2472 let content = fs::read_to_string(&toml_path).unwrap();
2474 assert!(content.contains("new-theme"));
2475 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2476 }
2477
2478 #[test]
2479 fn test_save_project_creates_json_by_default() {
2480 let tmp = tempfile::tempdir().unwrap();
2481 let oxi_dir = tmp.path().join(".oxi");
2482 fs::create_dir_all(&oxi_dir).unwrap();
2483 let mut settings = Settings::default();
2486 settings.theme = "json-theme".to_string();
2487 settings.save_project(tmp.path()).unwrap();
2488
2489 let json_path = oxi_dir.join("settings.json");
2491 assert!(json_path.exists());
2492 let content = fs::read_to_string(&json_path).unwrap();
2493 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2494 assert!(content.contains("json-theme"));
2495 }
2496
2497 #[test]
2500 fn test_custom_provider_default_api() {
2501 use super::CustomProvider;
2502 let cp = CustomProvider {
2503 name: "test".to_string(),
2504 base_url: "https://api.test.com/v1".to_string(),
2505 api_key_env: "TEST_API_KEY".to_string(),
2506 api: super::default_custom_provider_api(),
2507 };
2508 assert_eq!(cp.api, "openai-completions");
2509 }
2510
2511 #[test]
2512 fn test_custom_provider_toml_deserialize() {
2513 let toml_content = r#"
2514[[custom_providers]]
2515name = "minimax"
2516base_url = "https://api.minimax.chat/v1"
2517api_key_env = "MINIMAX_API_KEY"
2518api = "openai-completions"
2519
2520[[custom_providers]]
2521name = "zai"
2522base_url = "https://api.z.ai/v1"
2523api_key_env = "ZAI_API_KEY"
2524api = "openai-responses"
2525"#;
2526 let settings: Settings = toml::from_str(toml_content).unwrap();
2527 assert_eq!(settings.custom_providers.len(), 2);
2528 assert_eq!(settings.custom_providers[0].name, "minimax");
2529 assert_eq!(
2530 settings.custom_providers[0].base_url,
2531 "https://api.minimax.chat/v1"
2532 );
2533 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2534 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2535 assert_eq!(settings.custom_providers[1].name, "zai");
2536 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2537 }
2538
2539 #[test]
2540 fn test_custom_provider_json_deserialize() {
2541 let json_content = r#"{
2542 "custom_providers": [
2543 {
2544 "name": "minimax",
2545 "base_url": "https://api.minimax.chat/v1",
2546 "api_key_env": "MINIMAX_API_KEY",
2547 "api": "openai-completions"
2548 }
2549 ]
2550 }"#;
2551 let settings: Settings = serde_json::from_str(json_content).unwrap();
2552 assert_eq!(settings.custom_providers.len(), 1);
2553 assert_eq!(settings.custom_providers[0].name, "minimax");
2554 }
2555
2556 #[test]
2557 fn test_custom_provider_toml_roundtrip() {
2558 let mut settings = Settings::default();
2559 settings.custom_providers.push(super::CustomProvider {
2560 name: "test".to_string(),
2561 base_url: "https://api.test.com/v1".to_string(),
2562 api_key_env: "TEST_API_KEY".to_string(),
2563 api: "openai-completions".to_string(),
2564 });
2565
2566 let toml_str = toml::to_string_pretty(&settings).unwrap();
2567 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2568 assert_eq!(parsed.custom_providers.len(), 1);
2569 assert_eq!(parsed.custom_providers[0].name, "test");
2570 assert_eq!(
2571 parsed.custom_providers[0].base_url,
2572 "https://api.test.com/v1"
2573 );
2574 }
2575
2576 #[test]
2577 fn test_custom_provider_defaults_empty() {
2578 let settings = Settings::default();
2579 assert!(settings.custom_providers.is_empty());
2580 }
2581
2582 #[test]
2583 fn test_custom_provider_layer_file() {
2584 let base = Settings::default();
2585
2586 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2587 let toml_content = r#"
2588[[custom_providers]]
2589name = "my-provider"
2590base_url = "https://api.my-provider.com/v1"
2591api_key_env = "MY_PROVIDER_API_KEY"
2592"#;
2593 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2594
2595 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2596 assert_eq!(merged.custom_providers.len(), 1);
2597 assert_eq!(merged.custom_providers[0].name, "my-provider");
2598 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2600 }
2601}