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 = "default_false")]
401 pub ttsr_enabled: bool,
402
403 #[serde(default = "default_ttsr_mode")]
405 pub ttsr_interrupt_mode: String,
406
407 #[serde(default)]
418 pub model_roles: HashMap<String, String>,
419 #[serde(default)]
423 pub advisor: AdvisorSettings,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct AdvisorSettings {
434 #[serde(default = "default_false")]
436 pub enabled: bool,
437 #[serde(default = "default_advisor_sync_backlog")]
441 pub sync_backlog: String,
442 #[serde(default)]
446 pub immune_turns: u64,
447}
448
449impl Default for AdvisorSettings {
450 fn default() -> Self {
451 Self {
452 enabled: false,
453 sync_backlog: default_advisor_sync_backlog(),
454 immune_turns: 0,
455 }
456 }
457}
458
459fn default_advisor_sync_backlog() -> String {
460 "off".to_string()
461}
462
463fn default_theme() -> String {
464 "default".to_string()
465}
466
467fn default_thinking_level() -> ThinkingLevel {
468 ThinkingLevel::Medium
469}
470
471fn default_session_history_size() -> usize {
472 100
473}
474
475fn default_true() -> bool {
476 true
477}
478
479fn default_false() -> bool {
480 false
481}
482
483fn default_ttsr_mode() -> String {
484 "prose_only".to_string()
485}
486
487fn default_circuit_failure_threshold() -> u32 {
488 5
489}
490
491fn default_circuit_open_duration_secs() -> u64 {
492 30
493}
494
495fn default_tool_timeout() -> u64 {
496 120
497}
498
499impl Default for Settings {
500 fn default() -> Self {
501 Self {
502 version: SETTINGS_VERSION,
503 thinking_level: ThinkingLevel::Medium,
504 theme: default_theme(),
505 glyph_set: GlyphSet::default(),
506 last_used_model: None,
507 last_used_provider: None,
508 default_model: None,
509 default_provider: None,
510 max_tokens: None,
511 temperature: None,
512 default_temperature: None,
513 max_response_tokens: None,
514 session_history_size: default_session_history_size(),
515 session_dir: None,
516 stream_responses: true,
517 extensions_enabled: true,
518 auto_compaction: true,
519 disabled_tools: Vec::new(),
520 tool_timeout_seconds: default_tool_timeout(),
521 ask_timeout_secs: 0,
522 extensions: Vec::new(),
523 skills: Vec::new(),
524 prompts: Vec::new(),
525 themes: Vec::new(),
526 custom_providers: Vec::new(),
527 dynamic_models: HashMap::new(),
528 enable_routing: false,
530 router_profile: None,
531 prefer_cost_efficient: true,
532 fallback_chain: Vec::new(),
533 enable_fallback: true,
534 disable_fallback: false,
535 circuit_breaker_failure_threshold: 5,
536 circuit_breaker_open_duration_secs: 30,
537 keybindings: HashMap::new(),
538 output_languages: HashMap::new(),
539 language_policy_enabled: false,
540 edit_format: EditFormat::default(),
541 memory_enabled: false,
542 memory_db_path: None,
543 mnemopi_engine: false,
544 ttsr_enabled: false,
545 ttsr_interrupt_mode: default_ttsr_mode(),
546 model_roles: HashMap::new(),
547 advisor: AdvisorSettings::default(),
548 }
549 }
550}
551
552impl Settings {
553 pub fn settings_dir() -> Result<PathBuf> {
557 let base = dirs::home_dir().context("Cannot determine home directory")?;
558 Ok(base.join(".oxi"))
559 }
560
561 pub fn settings_toml_path() -> Result<PathBuf> {
563 Ok(Self::settings_dir()?.join("settings.toml"))
564 }
565
566 pub fn settings_json_path() -> Result<PathBuf> {
568 Ok(Self::settings_dir()?.join("settings.json"))
569 }
570
571 pub fn settings_path() -> Result<PathBuf> {
578 let json_path = Self::settings_json_path()?;
579 let toml_path = Self::settings_toml_path()?;
580
581 if json_path.exists() && toml_path.exists() {
582 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
584 return Ok(json_path);
585 }
586
587 if json_path.exists() {
588 return Ok(json_path);
589 }
590
591 if toml_path.exists() {
592 return Ok(toml_path);
593 }
594
595 Ok(json_path)
597 }
598
599 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
604 let json_path = Self::settings_json_path()?;
605 let toml_path = Self::settings_toml_path()?;
606
607 let (primary, secondary) = if prefer_json {
608 (&json_path, &toml_path)
609 } else {
610 (&toml_path, &json_path)
611 };
612
613 if primary.exists() {
614 return Ok(primary.clone());
615 }
616
617 if secondary.exists() {
618 return Ok(secondary.clone());
619 }
620
621 Ok(primary.clone())
623 }
624
625 pub fn detect_format(path: &Path) -> SettingsFormat {
627 match path.extension().and_then(|e| e.to_str()) {
628 Some("json") => SettingsFormat::Json,
629 Some("toml") => SettingsFormat::Toml,
630 _ => SettingsFormat::Json, }
632 }
633
634 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
639 let mut dir = start_dir.to_path_buf();
640 loop {
641 let json_candidate = dir.join(".oxi").join("settings.json");
643 if json_candidate.exists() {
644 return Some(json_candidate);
645 }
646
647 let toml_candidate = dir.join(".oxi").join("settings.toml");
648 if toml_candidate.exists() {
649 return Some(toml_candidate);
650 }
651
652 if !dir.pop() {
653 return None;
654 }
655 }
656 }
657
658 pub fn effective_session_dir(&self) -> Result<PathBuf> {
662 if let Some(ref dir) = self.session_dir {
663 return Ok(dir.clone());
664 }
665 Ok(Self::settings_dir()?.join("sessions"))
666 }
667
668 pub fn load() -> Result<Self> {
686 Self::load_from_cwd()
687 }
688
689 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
695 Self::load_from_with(dir, None)
696 }
697
698 pub fn load_from_with(
717 dir: &std::path::Path,
718 global_override: Option<&std::path::Path>,
719 ) -> Result<Self> {
720 let mut settings = Settings::default();
722
723 let resolved_global: Option<std::path::PathBuf> = match global_override {
726 Some(p) => Some(p.to_path_buf()),
727 None => Self::settings_path().ok(),
728 };
729 if let Some(ref gp) = resolved_global
730 && gp.exists()
731 {
732 settings = Self::layer_file(&settings, gp)?;
733 }
734
735 if let Some(project_path) = Self::find_project_settings(dir) {
737 settings = Self::layer_file(&settings, &project_path)?;
738 }
739
740 settings.apply_env();
742
743 settings = Self::migrate(settings)?;
745
746 settings.validate_output_languages();
748
749 Ok(settings)
750 }
751
752 fn validate_output_languages(&mut self) {
759 if self.output_languages.is_empty() {
760 return;
761 }
762 let known_langs: std::collections::HashSet<&str> =
763 KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
764
765 for (channel, lang) in &self.output_languages {
766 if !known_langs.contains(lang.as_str()) {
767 tracing::warn!(
768 "Unknown output_languages language code '{}' for channel '{}'. \
769 Keeping as-is (the model will likely understand).",
770 lang,
771 channel
772 );
773 }
774 }
775 }
776
777 pub fn load_from_cwd() -> Result<Self> {
779 let cwd = env::current_dir().context("Cannot determine current directory")?;
780 Self::load_from(&cwd)
781 }
782
783 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
789 let content = fs::read_to_string(path)
790 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
791
792 let format = Self::detect_format(path);
793 let overlay: serde_json::Value = match format {
794 SettingsFormat::Toml => {
795 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
796 format!("Failed to parse TOML settings from {}", path.display())
797 })?;
798 toml_value_to_json(toml_value)
800 }
801 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
802 format!("Failed to parse JSON settings from {}", path.display())
803 })?,
804 };
805
806 let base_json =
810 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
811
812 let merged = merge_json_values(base_json, overlay);
813 let result: Settings =
814 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
815
816 Ok(result)
817 }
818
819 #[allow(dead_code)]
845 pub fn apply_env(&mut self) {
846 }
850
851 #[allow(dead_code)]
857 pub fn from_env() -> Self {
858 Self::default()
859 }
860
861 pub fn save(&self) -> Result<()> {
868 let dir = Self::settings_dir()?;
869 let path = Self::settings_path()?;
870
871 if !dir.exists() {
872 fs::create_dir_all(&dir).with_context(|| {
873 format!("Failed to create settings directory {}", dir.display())
874 })?;
875 }
876
877 let format = Self::detect_format(&path);
878 let content = Self::serialize_for_format(self, format)?;
879
880 let tmp_path = path.with_extension("tmp");
882 fs::write(&tmp_path, &content)
883 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
884 fs::rename(&tmp_path, &path)
885 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
886
887 Ok(())
888 }
889
890 pub fn save_to(&self, path: &Path) -> Result<()> {
892 if let Some(parent) = path.parent()
893 && !parent.exists()
894 {
895 fs::create_dir_all(parent)
896 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
897 }
898
899 let format = Self::detect_format(path);
900 let content = Self::serialize_for_format(self, format)?;
901
902 let tmp_path = path.with_extension("tmp");
904 fs::write(&tmp_path, &content)
905 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
906 fs::rename(&tmp_path, path)
907 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
908
909 Ok(())
910 }
911
912 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
916 let dir = project_dir.join(".oxi");
917
918 if !dir.exists() {
919 fs::create_dir_all(&dir).with_context(|| {
920 format!(
921 "Failed to create project settings directory {}",
922 dir.display()
923 )
924 })?;
925 }
926
927 let json_path = dir.join("settings.json");
929 let toml_path = dir.join("settings.toml");
930
931 let path = if json_path.exists() {
932 &json_path
933 } else if toml_path.exists() {
934 &toml_path
935 } else {
936 &json_path
938 };
939
940 let format = Self::detect_format(path);
941 let content = Self::serialize_for_format(self, format)?;
942
943 let tmp_path = path.with_extension("tmp");
945 fs::write(&tmp_path, &content)
946 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
947 fs::rename(&tmp_path, path)
948 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
949
950 Ok(())
951 }
952
953 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
955 match format {
956 SettingsFormat::Toml => {
957 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
958 }
959 SettingsFormat::Json => serde_json::to_string_pretty(settings)
960 .context("Failed to serialize settings to JSON"),
961 }
962 }
963
964 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
966 match format {
967 SettingsFormat::Toml => {
968 toml::from_str(content).context("Failed to parse TOML settings")
969 }
970 SettingsFormat::Json => {
971 serde_json::from_str(content).context("Failed to parse JSON settings")
972 }
973 }
974 }
975
976 pub fn merge_cli(
989 &mut self,
990 model: Option<String>,
991 provider: Option<String>,
992 enable_routing: Option<bool>,
993 prefer_cost_efficient: Option<bool>,
994 fallback_chain: Option<Vec<String>>,
995 disable_fallback: Option<bool>,
996 ) {
997 if let Some(m) = model {
998 self.last_used_model = Some(m);
999 }
1000 if let Some(p) = provider {
1001 self.last_used_provider = Some(p);
1002 }
1003 if let Some(r) = enable_routing {
1004 self.enable_routing = r;
1005 }
1006 if let Some(p) = prefer_cost_efficient {
1007 self.prefer_cost_efficient = p;
1008 }
1009 if let Some(fc) = fallback_chain
1010 && !fc.is_empty()
1011 {
1012 self.fallback_chain = fc;
1013 }
1014 if let Some(df) = disable_fallback {
1015 self.disable_fallback = df;
1016 if df {
1018 self.enable_fallback = false;
1019 }
1020 }
1021 }
1022
1023 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
1026 cli_model.map(String::from).or_else(|| {
1027 let model = self.last_used_model.as_ref()?;
1032 if model.contains('/') {
1033 Some(model.clone())
1035 } else if let Some(ref provider) = self.last_used_provider {
1036 Some(format!("{}/{}", provider, model))
1038 } else {
1039 Some(model.clone())
1040 }
1041 })
1042 }
1043
1044 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
1047 cli_provider
1048 .map(String::from)
1049 .or_else(|| self.last_used_provider.clone())
1050 }
1051
1052 pub fn effective_temperature(&self) -> Option<f64> {
1055 self.default_temperature
1056 .or(self.temperature.map(|t| t as f64))
1057 }
1058
1059 pub fn effective_max_tokens(&self) -> Option<usize> {
1062 self.max_response_tokens
1063 .or(self.max_tokens.map(|t| t as usize))
1064 }
1065
1066 pub fn router_profile(&self) -> Option<&str> {
1068 self.router_profile.as_deref()
1069 }
1070
1071 pub fn save_last_used(model_id: &str) {
1077 if let Ok(mut settings) = Self::load() {
1078 if let Some((provider, model)) = model_id.split_once('/') {
1079 settings.last_used_provider = Some(provider.to_string());
1080 settings.last_used_model = Some(model.to_string());
1081 } else {
1082 settings.last_used_model = Some(model_id.to_string());
1083 }
1084 let _ = settings.save();
1085 }
1086 }
1087
1088 pub fn save_theme(&mut self, name: &str) -> Result<()> {
1090 self.theme = name.to_string();
1091 self.save()
1092 }
1093
1094 pub fn get_theme_name(&self) -> String {
1096 if self.theme.is_empty() || self.theme == "default" {
1097 "oxi_dark".to_string()
1098 } else {
1099 self.theme.clone()
1100 }
1101 }
1102
1103 fn migrate(settings: Settings) -> Result<Settings> {
1119 let mut settings = settings;
1120
1121 match settings.version {
1122 SETTINGS_VERSION => {
1123 }
1125 0 => {
1126 if settings.tool_timeout_seconds == 0 {
1129 settings.tool_timeout_seconds = default_tool_timeout();
1130 }
1131 settings.version = SETTINGS_VERSION;
1132
1133 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1134 }
1135 1 | 2 => {
1136 settings.version = SETTINGS_VERSION;
1141 tracing::info!(
1142 "Migrated settings from version {} to {} (dynamic_models + output_languages + language_policy_enabled defaults applied)",
1143 settings.version,
1144 SETTINGS_VERSION
1145 );
1146 }
1147 3 => {
1148 if let Some(model) = settings.default_model.take() {
1150 if let Some((provider, model_name)) = model.split_once('/') {
1151 settings.last_used_provider = Some(provider.to_string());
1152 settings.last_used_model = Some(model_name.to_string());
1153 } else {
1154 settings.last_used_model = Some(model);
1155 }
1156 }
1157 settings.version = SETTINGS_VERSION;
1159 tracing::info!(
1160 "Migrated settings from version 3 to {} (default_model → last_used_model; output_languages + language_policy_enabled defaults)",
1161 SETTINGS_VERSION
1162 );
1163 }
1164 4 => {
1165 settings.version = SETTINGS_VERSION;
1169 tracing::info!(
1170 "Migrated settings from version 4 to {} (added output_languages + language_policy_enabled, both defaulted to off)",
1171 SETTINGS_VERSION
1172 );
1173 }
1174 5 => {
1175 settings.version = SETTINGS_VERSION;
1181 tracing::info!(
1182 "Migrated settings from version 5 to {} (added language_policy_enabled, defaulting to OFF — toggle ON in /settings to activate existing channels)",
1183 SETTINGS_VERSION
1184 );
1185 }
1186 6 => {
1187 settings.version = SETTINGS_VERSION;
1190 tracing::info!(
1191 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1192 SETTINGS_VERSION
1193 );
1194 }
1195 7 => {
1196 settings.version = SETTINGS_VERSION;
1199 tracing::info!(
1200 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1201 SETTINGS_VERSION
1202 );
1203 }
1204 8 => {
1205 settings.version = SETTINGS_VERSION;
1208 tracing::info!(
1209 "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1210 SETTINGS_VERSION
1211 );
1212 }
1213 v if v > SETTINGS_VERSION => {
1214 anyhow::bail!(
1216 "Settings version {} is newer than supported version {}. \
1217 Please update oxi.",
1218 v,
1219 SETTINGS_VERSION
1220 );
1221 }
1222 v => {
1223 tracing::warn!(
1225 "Unknown settings version {}, attempting migration to {}",
1226 v,
1227 SETTINGS_VERSION
1228 );
1229 settings.version = SETTINGS_VERSION;
1230 }
1231 }
1232
1233 Ok(settings)
1234 }
1235}
1236
1237#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1241pub enum SettingsFormat {
1242 #[default]
1244 Json,
1245 Toml,
1247}
1248
1249impl SettingsFormat {
1250 pub fn extension(&self) -> &'static str {
1252 match self {
1253 SettingsFormat::Json => "json",
1254 SettingsFormat::Toml => "toml",
1255 }
1256 }
1257}
1258
1259fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1263 match toml {
1264 toml::Value::String(s) => serde_json::Value::String(s),
1265 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1266 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1267 .map(serde_json::Value::Number)
1268 .unwrap_or(serde_json::Value::Null),
1269 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1270 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1271 toml::Value::Array(arr) => {
1272 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1273 }
1274 toml::Value::Table(table) => {
1275 let obj = table
1276 .into_iter()
1277 .map(|(k, v)| (k, toml_value_to_json(v)))
1278 .collect();
1279 serde_json::Value::Object(obj)
1280 }
1281 }
1282}
1283
1284fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1286 match (base, override_) {
1287 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1289 let mut result = base_map;
1290 for (key, override_value) in override_map {
1291 let base_value = result.remove(&key);
1292 let merged = match base_value {
1293 Some(base_v) => merge_json_values(base_v, override_value),
1294 None => override_value,
1295 };
1296 result.insert(key, merged);
1297 }
1298 serde_json::Value::Object(result)
1299 }
1300 (_, override_) => override_,
1302 }
1303}
1304
1305pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1307 match s.to_lowercase().as_str() {
1308 "off" | "none" => Some(ThinkingLevel::Off),
1309 "minimal" => Some(ThinkingLevel::Minimal),
1310 "low" => Some(ThinkingLevel::Low),
1311 "medium" | "standard" => Some(ThinkingLevel::Medium),
1312 "high" | "thorough" => Some(ThinkingLevel::High),
1313 "xhigh" => Some(ThinkingLevel::XHigh),
1314 _ => None,
1315 }
1316}
1317
1318#[allow(dead_code)]
1320fn parse_boolish(s: &str) -> Result<bool> {
1321 match s.to_lowercase().as_str() {
1322 "true" | "1" | "yes" | "on" => Ok(true),
1323 "false" | "0" | "no" | "off" => Ok(false),
1324 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1325 }
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330 use super::*;
1331 use std::io::Write as IoWrite;
1332 use std::sync::Mutex;
1333
1334 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1337
1338 struct EnvGuard {
1341 saved: Vec<(String, Option<String>)>,
1342 }
1343
1344 impl EnvGuard {
1345 fn new(vars: &[&str]) -> Self {
1346 let saved = vars
1347 .iter()
1348 .map(|&name| {
1349 let old = env::var(name).ok();
1350 unsafe { env::remove_var(name) };
1352 (name.to_string(), old)
1353 })
1354 .collect();
1355 Self { saved }
1356 }
1357 }
1358
1359 impl Drop for EnvGuard {
1360 fn drop(&mut self) {
1361 for (name, old) in self.saved.drain(..) {
1362 match old {
1363 Some(val) => unsafe { env::set_var(&name, val) },
1365 None => unsafe { env::remove_var(&name) },
1366 }
1367 }
1368 }
1369 }
1370
1371 #[test]
1374 fn test_default_settings() {
1375 let settings = Settings::default();
1376 assert_eq!(settings.version, SETTINGS_VERSION);
1377 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1378 assert_eq!(settings.theme, "default");
1379 assert!(settings.last_used_model.is_none());
1380 assert!(settings.last_used_provider.is_none());
1381 assert!(settings.extensions_enabled);
1382 assert!(settings.auto_compaction);
1383 assert_eq!(settings.tool_timeout_seconds, 120);
1384 assert!(settings.stream_responses);
1385 }
1386
1387 #[test]
1388 fn test_merge_cli() {
1389 let mut settings = Settings::default();
1390 settings.last_used_model = Some("gpt-4o".to_string());
1391
1392 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1393 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1394
1395 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1396 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1397
1398 settings.merge_cli(
1400 None,
1401 None,
1402 Some(true),
1403 Some(false),
1404 Some(vec!["openai/gpt-4o".to_string()]),
1405 Some(false),
1406 );
1407 assert!(settings.enable_routing);
1408 assert!(!settings.prefer_cost_efficient);
1409 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1410 assert!(!settings.disable_fallback);
1411
1412 let mut settings2 = Settings::default();
1414 settings2.merge_cli(None, None, None, None, None, Some(true));
1415 assert!(settings2.disable_fallback);
1416 assert!(!settings2.enable_fallback);
1417 }
1418
1419 #[test]
1422 fn test_layer_file_overrides() {
1423 let base = Settings::default();
1424
1425 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1426 let toml_content = r#"
1427last_used_model = "openai/gpt-4o"
1428theme = "dracula"
1429"#;
1430 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1431
1432 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1433 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1434 assert_eq!(merged.theme, "dracula");
1435 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1437 assert!(merged.extensions_enabled);
1438 }
1439
1440 #[test]
1441 fn test_layer_file_preserves_unset() {
1442 let mut base = Settings::default();
1443 base.last_used_provider = Some("deepseek".to_string());
1444
1445 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1446 let toml_content = "theme = \"monokai\"\n";
1448 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1449
1450 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1451 assert_eq!(merged.theme, "monokai");
1452 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1453 }
1454
1455 #[test]
1456 fn test_load_from_dir_with_project_config() {
1457 let _guard = EnvGuard::new(&[
1458 "OXI_MODEL",
1459 "OXI_PROVIDER",
1460 "OXI_THEME",
1461 "OXI_TOOL_TIMEOUT",
1462 "OXI_TEMPERATURE",
1463 "OXI_MAX_TOKENS",
1464 "OXI_SESSION_DIR",
1465 "OXI_STREAM",
1466 "OXI_EXTENSIONS_ENABLED",
1467 ]);
1468 let tmp = tempfile::tempdir().unwrap();
1469 let oxi_dir = tmp.path().join(".oxi");
1470 fs::create_dir_all(&oxi_dir).unwrap();
1471 let settings_path = oxi_dir.join("settings.toml");
1472 fs::write(
1474 &settings_path,
1475 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1476 )
1477 .unwrap();
1478
1479 let settings = Settings::load_from(tmp.path()).unwrap();
1480 assert_eq!(
1482 settings.last_used_model,
1483 Some("gemini-2.0-flash".to_string())
1484 );
1485 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1486 }
1487
1488 #[test]
1489 fn test_load_from_dir_no_config() {
1490 let _guard = EnvGuard::new(&[
1492 "OXI_MODEL",
1493 "OXI_PROVIDER",
1494 "OXI_THEME",
1495 "OXI_TOOL_TIMEOUT",
1496 "OXI_TEMPERATURE",
1497 "OXI_MAX_TOKENS",
1498 "OXI_SESSION_DIR",
1499 "OXI_STREAM",
1500 "OXI_EXTENSIONS_ENABLED",
1501 ]);
1502 let tmp = tempfile::tempdir().unwrap();
1503 let global = tmp.path().join("nonexistent-settings.json");
1508 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1509 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1510 }
1511 #[test]
1512 fn test_from_env() {
1513 let _guard = EnvGuard::new(&[
1516 "OXI_MODEL",
1518 "OXI_THEME",
1519 "OXI_TOOL_TIMEOUT",
1520 "OXI_PROVIDER",
1521 "OXI_DEFAULT_MODEL",
1522 ]);
1523
1524 let settings = Settings::from_env();
1525 assert_eq!(settings.last_used_model, None);
1527 assert_eq!(settings.theme, "default");
1528 assert_eq!(settings.tool_timeout_seconds, 120);
1529 }
1530
1531 #[test]
1532 fn test_apply_env_boolish() {
1533 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1536 unsafe { env::set_var("OXI_STREAM", "false") };
1537 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1538
1539 let mut settings = Settings::default();
1540 settings.apply_env();
1541 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1545
1546 #[test]
1547 fn test_apply_env_temperature() {
1548 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1550 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1551
1552 let mut settings = Settings::default();
1553 settings.apply_env();
1554 assert_eq!(settings.default_temperature, None);
1556 }
1557
1558 #[test]
1559 fn test_env_does_not_override_when_unset() {
1560 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1561 let settings = Settings::from_env();
1562 assert!(settings.last_used_model.is_none());
1563 assert!(settings.last_used_provider.is_none());
1564 }
1565
1566 #[test]
1567 fn test_parse_thinking_level() {
1568 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1569 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1570 assert_eq!(
1571 parse_thinking_level("MINIMAL"),
1572 Some(ThinkingLevel::Minimal)
1573 );
1574 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1575 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1576 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1577 assert_eq!(
1578 parse_thinking_level("Standard"),
1579 Some(ThinkingLevel::Medium)
1580 );
1581 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1582 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1583 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1584 assert_eq!(parse_thinking_level("invalid"), None);
1585 }
1586
1587 #[test]
1588 fn test_parse_boolish() {
1589 assert!(parse_boolish("true").unwrap());
1590 assert!(parse_boolish("1").unwrap());
1591 assert!(parse_boolish("yes").unwrap());
1592 assert!(parse_boolish("ON").unwrap());
1593 assert!(!parse_boolish("false").unwrap());
1594 assert!(!parse_boolish("0").unwrap());
1595 assert!(!parse_boolish("no").unwrap());
1596 assert!(!parse_boolish("OFF").unwrap());
1597 assert!(parse_boolish("maybe").is_err());
1598 }
1599
1600 #[test]
1603 fn test_effective_model_returns_last_used() {
1604 let mut settings = Settings::default();
1605 settings.last_used_model = Some("openai/gpt-4o".to_string());
1606 assert_eq!(
1607 settings.effective_model(None),
1608 Some("openai/gpt-4o".to_string())
1609 );
1610 }
1611
1612 #[test]
1613 fn test_effective_model_cli_overrides() {
1614 let mut settings = Settings::default();
1615 settings.last_used_model = Some("openai/gpt-4o".to_string());
1616 assert_eq!(
1617 settings.effective_model(Some("anthropic/claude-3")),
1618 Some("anthropic/claude-3".to_string())
1619 );
1620 }
1621
1622 #[test]
1623 fn test_effective_model_none_when_unset() {
1624 let settings = Settings::default();
1625 assert_eq!(settings.effective_model(None), None);
1626 }
1627
1628 #[test]
1629 fn test_effective_model_falls_back_to_last_used() {
1630 let mut settings = Settings::default();
1631 settings.last_used_model = Some("anthropic/claude-3".to_string());
1632 assert_eq!(
1633 settings.effective_model(None),
1634 Some("anthropic/claude-3".to_string())
1635 );
1636 }
1637
1638 #[test]
1639 fn test_effective_model_returns_none_when_nothing_set() {
1640 let settings = Settings::default();
1641 assert_eq!(settings.effective_model(None), None);
1642 }
1643
1644 #[test]
1645 fn test_effective_temperature_prefers_f64() {
1646 let mut settings = Settings::default();
1647 settings.temperature = Some(0.5);
1648 settings.default_temperature = Some(0.7);
1649 assert_eq!(settings.effective_temperature(), Some(0.7));
1650 }
1651
1652 #[test]
1653 fn test_effective_temperature_falls_back_to_f32() {
1654 let mut settings = Settings::default();
1655 settings.temperature = Some(0.5);
1656 assert_eq!(settings.effective_temperature(), Some(0.5));
1657 }
1658
1659 #[test]
1660 fn test_effective_max_tokens_prefers_usize() {
1661 let mut settings = Settings::default();
1662 settings.max_tokens = Some(1024);
1663 settings.max_response_tokens = Some(4096);
1664 assert_eq!(settings.effective_max_tokens(), Some(4096));
1665 }
1666
1667 #[test]
1668 fn test_effective_max_tokens_falls_back_to_u32() {
1669 let mut settings = Settings::default();
1670 settings.max_tokens = Some(1024);
1671 assert_eq!(settings.effective_max_tokens(), Some(1024));
1672 }
1673
1674 #[test]
1677 fn test_effective_session_dir_default() {
1678 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1679 let settings = Settings::default();
1680 let dir = settings.effective_session_dir().unwrap();
1681 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1682 }
1683
1684 #[test]
1685 fn test_effective_session_dir_from_field() {
1686 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1687 let mut settings = Settings::default();
1688 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1689 assert_eq!(
1690 settings.effective_session_dir().unwrap(),
1691 PathBuf::from("/tmp/oxi-sessions")
1692 );
1693 }
1694
1695 #[test]
1696 fn test_effective_session_dir_env_disabled() {
1697 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1700 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1701 let settings = Settings::default();
1702 let dir = settings.effective_session_dir().unwrap();
1704 assert!(
1705 dir.ends_with("sessions"),
1706 "expected default sessions dir, got: {:?}",
1707 dir
1708 );
1709 }
1710
1711 #[test]
1714 fn test_migration_v0_to_v1() {
1715 let mut settings = Settings::default();
1716 settings.version = 0;
1717 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1720 assert_eq!(migrated.version, SETTINGS_VERSION);
1721 assert_eq!(migrated.tool_timeout_seconds, 120);
1722 }
1723
1724 #[test]
1725 fn test_migration_already_current() {
1726 let settings = Settings::default();
1727 let migrated = Settings::migrate(settings).unwrap();
1728 assert_eq!(migrated.version, SETTINGS_VERSION);
1729 }
1730
1731 #[test]
1732 fn test_migration_v3_to_v4_splits_model() {
1733 let mut settings = Settings::default();
1734 settings.version = 3;
1735 settings.default_model = Some("openai/gpt-4o".to_string());
1736 settings.default_provider = None;
1737
1738 let migrated = Settings::migrate(settings).unwrap();
1739 assert_eq!(migrated.version, SETTINGS_VERSION);
1740 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1741 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1742 }
1743
1744 #[test]
1745 fn test_migration_v3_no_slash_keeps_model() {
1746 let mut settings = Settings::default();
1747 settings.version = 3;
1748 settings.default_model = Some("bare-model-name".to_string());
1749
1750 let migrated = Settings::migrate(settings).unwrap();
1751 assert_eq!(migrated.version, SETTINGS_VERSION);
1752 assert_eq!(
1753 migrated.last_used_model,
1754 Some("bare-model-name".to_string())
1755 );
1756 }
1757
1758 #[test]
1759 fn test_migration_future_version_fails() {
1760 let mut settings = Settings::default();
1761 settings.version = 9999;
1762 assert!(Settings::migrate(settings).is_err());
1763 }
1764
1765 #[test]
1768 fn test_default_output_languages_is_empty() {
1769 let settings = Settings::default();
1770 assert!(
1771 settings.output_languages.is_empty(),
1772 "all channels should default to auto (empty map)"
1773 );
1774 }
1775
1776 #[test]
1777 fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1778 let mut settings = Settings::default();
1779 settings.version = 4;
1780 settings
1781 .output_languages
1782 .insert("response".to_string(), "ko".to_string());
1783 settings
1784 .output_languages
1785 .insert("commit_message".to_string(), "en".to_string());
1786
1787 let migrated = Settings::migrate(settings).unwrap();
1788 assert_eq!(migrated.version, SETTINGS_VERSION);
1789 assert_eq!(
1790 migrated.output_languages.get("response"),
1791 Some(&"ko".to_string())
1792 );
1793 assert_eq!(
1794 migrated.output_languages.get("commit_message"),
1795 Some(&"en".to_string())
1796 );
1797 }
1798
1799 #[test]
1800 fn test_migration_v4_to_v5_creates_empty_if_missing() {
1801 let mut settings = Settings::default();
1805 settings.version = 4;
1806 assert!(settings.output_languages.is_empty());
1807
1808 let migrated = Settings::migrate(settings).unwrap();
1809 assert_eq!(migrated.version, SETTINGS_VERSION);
1810 assert!(migrated.output_languages.is_empty());
1811 }
1812
1813 #[test]
1814 fn test_validate_keeps_user_defined_channel() {
1815 let mut settings = Settings::default();
1820 settings
1821 .output_languages
1822 .insert("pr_description".to_string(), "en".to_string()); settings
1824 .output_languages
1825 .insert("response".to_string(), "ko".to_string()); settings.validate_output_languages();
1828
1829 assert!(settings.output_languages.contains_key("pr_description"));
1830 assert!(settings.output_languages.contains_key("response"));
1831 assert_eq!(
1832 settings.output_languages.get("pr_description"),
1833 Some(&"en".to_string())
1834 );
1835 assert_eq!(
1836 settings.output_languages.get("response"),
1837 Some(&"ko".to_string())
1838 );
1839 }
1840
1841 #[test]
1842 fn test_validate_keeps_unknown_lang_with_warning() {
1843 let mut settings = Settings::default();
1844 settings
1845 .output_languages
1846 .insert("response".to_string(), "klingon".to_string()); settings
1848 .output_languages
1849 .insert("commit_message".to_string(), "en".to_string()); settings.validate_output_languages();
1852
1853 assert_eq!(
1856 settings.output_languages.get("response"),
1857 Some(&"klingon".to_string())
1858 );
1859 assert_eq!(
1860 settings.output_languages.get("commit_message"),
1861 Some(&"en".to_string())
1862 );
1863 }
1864
1865 #[test]
1866 fn test_known_channels_table_includes_core_four() {
1867 let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1868 assert!(keys.contains(&"response"));
1869 assert!(keys.contains(&"code_comment"));
1870 assert!(keys.contains(&"documentation"));
1871 assert!(keys.contains(&"commit_message"));
1872 }
1873
1874 #[test]
1875 fn test_known_langs_table_includes_auto_and_english() {
1876 let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1877 assert!(codes.contains(&"auto"));
1878 assert!(codes.contains(&"en"));
1879 }
1880
1881 #[test]
1882 fn test_default_language_policy_enabled_is_false() {
1883 let settings = Settings::default();
1885 assert!(
1886 !settings.language_policy_enabled,
1887 "language_policy_enabled must default to false (opt-in)"
1888 );
1889 }
1890
1891 #[test]
1892 fn test_migration_v5_to_v6_defaults_master_toggle_to_off() {
1893 let mut settings = Settings::default();
1897 settings.version = 5;
1898 settings
1899 .output_languages
1900 .insert("response".to_string(), "ko".to_string());
1901 settings
1902 .output_languages
1903 .insert("commit_message".to_string(), "en".to_string());
1904
1905 let migrated = Settings::migrate(settings).unwrap();
1906 assert_eq!(migrated.version, SETTINGS_VERSION);
1907 assert!(
1908 !migrated.language_policy_enabled,
1909 "v5 → v6 migration must default language_policy_enabled to false"
1910 );
1911 assert_eq!(
1913 migrated.output_languages.get("response"),
1914 Some(&"ko".to_string())
1915 );
1916 assert_eq!(
1917 migrated.output_languages.get("commit_message"),
1918 Some(&"en".to_string())
1919 );
1920 }
1921
1922 #[test]
1923 fn test_default_glyph_set_is_unicode() {
1924 let settings = Settings::default();
1925 assert_eq!(
1926 settings.glyph_set,
1927 GlyphSet::Unicode,
1928 "glyph_set must default to Unicode"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1934 let mut settings = Settings::default();
1937 settings.version = 7;
1938 settings.glyph_set = GlyphSet::default();
1940
1941 let migrated = Settings::migrate(settings).unwrap();
1942 assert_eq!(migrated.version, SETTINGS_VERSION);
1943 assert_eq!(
1944 migrated.glyph_set,
1945 GlyphSet::Unicode,
1946 "v7 → v8 migration must default glyph_set to unicode"
1947 );
1948 }
1949
1950 #[test]
1951 fn test_glyph_set_persists_through_roundtrip() {
1952 let mut original = Settings::default();
1956 original.glyph_set = GlyphSet::Nerd;
1957 let content = toml::to_string_pretty(&original).unwrap();
1958 assert!(
1959 content.contains("glyph_set = \"nerd\""),
1960 "nerd preset must serialize to snake_case; got:\n{content}"
1961 );
1962 let loaded: Settings = toml::from_str(&content).unwrap();
1963 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1964 original.glyph_set = GlyphSet::Unicode;
1966 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1967 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1968 }
1969
1970 #[test]
1971 fn test_save_and_load_roundtrip_preserves_language_policy_enabled() {
1972 let tmp = tempfile::tempdir().unwrap();
1973 let settings_path = tmp.path().join("settings.toml");
1974
1975 let mut original = Settings::default();
1976 original.language_policy_enabled = true;
1977 original
1978 .output_languages
1979 .insert("response".to_string(), "ko".to_string());
1980
1981 let content = toml::to_string_pretty(&original).unwrap();
1982 fs::write(&settings_path, &content).unwrap();
1983
1984 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1985 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1986
1987 assert!(loaded.language_policy_enabled);
1988 assert_eq!(
1989 loaded.output_languages.get("response"),
1990 Some(&"ko".to_string())
1991 );
1992 }
1993
1994 #[test]
1995 fn test_save_and_load_roundtrip_preserves_output_languages() {
1996 let tmp = tempfile::tempdir().unwrap();
1997 let settings_path = tmp.path().join("settings.toml");
1998
1999 let mut original = Settings::default();
2000 original
2001 .output_languages
2002 .insert("response".to_string(), "ko".to_string());
2003 original
2004 .output_languages
2005 .insert("commit_message".to_string(), "en".to_string());
2006
2007 let content = toml::to_string_pretty(&original).unwrap();
2008 fs::write(&settings_path, &content).unwrap();
2009
2010 let loaded_content = fs::read_to_string(&settings_path).unwrap();
2011 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2012
2013 assert_eq!(
2014 loaded.output_languages.get("response"),
2015 Some(&"ko".to_string())
2016 );
2017 assert_eq!(
2018 loaded.output_languages.get("commit_message"),
2019 Some(&"en".to_string())
2020 );
2021 }
2022
2023 #[test]
2026 fn test_save_and_load_roundtrip() {
2027 let tmp = tempfile::tempdir().unwrap();
2028 let settings_path = tmp.path().join("settings.toml");
2029
2030 let mut original = Settings::default();
2031 original.last_used_model = Some("gpt-4o".to_string());
2032 original.last_used_provider = Some("openai".to_string());
2033 original.theme = "dracula".to_string();
2034 original.tool_timeout_seconds = 60;
2035
2036 let content = toml::to_string_pretty(&original).unwrap();
2038 fs::write(&settings_path, &content).unwrap();
2039
2040 let loaded_content = fs::read_to_string(&settings_path).unwrap();
2042 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
2043
2044 assert_eq!(loaded.last_used_model, original.last_used_model);
2045 assert_eq!(loaded.theme, original.theme);
2046 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
2047 }
2048
2049 #[test]
2050 fn test_toml_roundtrip_preserves_new_fields() {
2051 let mut settings = Settings::default();
2052 settings.default_temperature = Some(0.8);
2053 settings.max_response_tokens = Some(8192);
2054 settings.auto_compaction = false;
2055 settings.extensions_enabled = false;
2056 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
2057
2058 let toml_str = toml::to_string_pretty(&settings).unwrap();
2059 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2060
2061 assert_eq!(parsed.default_temperature, Some(0.8));
2062 assert_eq!(parsed.max_response_tokens, Some(8192));
2063 assert!(!parsed.auto_compaction);
2064 assert!(!parsed.extensions_enabled);
2065 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
2066 }
2067
2068 #[test]
2071 fn test_json_roundtrip() {
2072 let mut settings = Settings::default();
2073 settings.last_used_model = Some("gpt-4o".to_string());
2074 settings.last_used_provider = Some("openai".to_string());
2075 settings.theme = "dracula".to_string();
2076 settings.tool_timeout_seconds = 60;
2077 settings.default_temperature = Some(0.8);
2078 settings.max_response_tokens = Some(8192);
2079
2080 let json_str = serde_json::to_string_pretty(&settings).unwrap();
2081 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
2082
2083 assert_eq!(parsed.last_used_model, settings.last_used_model);
2084 assert_eq!(parsed.theme, settings.theme);
2085 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
2086 assert_eq!(parsed.default_temperature, settings.default_temperature);
2087 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
2088 }
2089
2090 #[test]
2091 fn test_json_serialize_for_format() {
2092 let mut settings = Settings::default();
2093 settings.last_used_model = Some("claude-3".to_string());
2094 settings.last_used_provider = Some("anthropic".to_string());
2095 settings.thinking_level = ThinkingLevel::Minimal;
2096
2097 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
2098 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
2099
2100 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
2101 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
2102 }
2103
2104 #[test]
2105 fn test_toml_serialize_for_format() {
2106 let mut settings = Settings::default();
2107 settings.last_used_model = Some("gemini-pro".to_string());
2108 settings.last_used_provider = Some("google".to_string());
2109 settings.thinking_level = ThinkingLevel::High;
2110
2111 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
2112 let parsed: Settings = toml::from_str(&toml_content).unwrap();
2113
2114 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2115 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
2116 }
2117
2118 #[test]
2119 fn test_parse_from_str_json() {
2120 let json_content = r#"{
2121 "last_used_model": "gpt-4",
2122 "last_used_provider": "openai",
2123 "theme": "nord",
2124 "tool_timeout_seconds": 90
2125 }"#;
2126
2127 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
2128 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
2129 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
2130 assert_eq!(settings.theme, "nord");
2131 assert_eq!(settings.tool_timeout_seconds, 90);
2132 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2134 assert!(settings.extensions_enabled);
2135 }
2136
2137 #[test]
2138 fn test_parse_from_str_toml() {
2139 let toml_content = r#"
2140last_used_model = "claude-opus"
2141last_used_provider = "anthropic"
2142theme = "monokai"
2143tool_timeout_seconds = 45
2144"#;
2145
2146 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
2147 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
2148 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
2149 assert_eq!(settings.theme, "monokai");
2150 assert_eq!(settings.tool_timeout_seconds, 45);
2151 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2152 }
2153
2154 #[test]
2155 fn test_layer_file_json() {
2156 let base = Settings::default();
2157
2158 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2159 let json_content = r#"{
2160 "last_used_model": "gpt-4o",
2161 "last_used_provider": "openai",
2162 "theme": "dracula",
2163 "auto_compaction": false
2164 }"#;
2165 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2166
2167 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2168 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
2169 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
2170 assert_eq!(merged.theme, "dracula");
2171 assert!(!merged.auto_compaction);
2172 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
2174 assert!(merged.extensions_enabled);
2175 assert_eq!(merged.tool_timeout_seconds, 120);
2176 }
2177
2178 #[test]
2179 fn test_layer_file_json_preserves_unset() {
2180 let mut base = Settings::default();
2181 base.last_used_provider = Some("deepseek".to_string());
2182
2183 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2184 let json_content = r#"{ "theme": "nord" }"#;
2185 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2186
2187 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2188 assert_eq!(merged.theme, "nord");
2189 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
2190 }
2191
2192 #[test]
2193 fn test_save_to_json() {
2194 let tmp = tempfile::tempdir().unwrap();
2195 let settings_path = tmp.path().join("settings.json");
2196
2197 let mut settings = Settings::default();
2198 settings.last_used_model = Some("gpt-4o".to_string());
2199 settings.last_used_provider = Some("openai".to_string());
2200 settings.theme = "dracula".to_string();
2201 settings.tool_timeout_seconds = 60;
2202
2203 settings.save_to(&settings_path).unwrap();
2204
2205 let content = fs::read_to_string(&settings_path).unwrap();
2207 let parsed: Settings = serde_json::from_str(&content).unwrap();
2208 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
2209 assert_eq!(parsed.theme, "dracula");
2210 assert_eq!(parsed.tool_timeout_seconds, 60);
2211 }
2212
2213 #[test]
2214 fn test_save_to_toml() {
2215 let tmp = tempfile::tempdir().unwrap();
2216 let settings_path = tmp.path().join("settings.toml");
2217
2218 let mut settings = Settings::default();
2219 settings.last_used_model = Some("gemini-pro".to_string());
2220 settings.last_used_provider = Some("google".to_string());
2221 settings.theme = "monokai".to_string();
2222 settings.tool_timeout_seconds = 90;
2223
2224 settings.save_to(&settings_path).unwrap();
2225
2226 let content = fs::read_to_string(&settings_path).unwrap();
2228 let parsed: Settings = toml::from_str(&content).unwrap();
2229 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2230 assert_eq!(parsed.theme, "monokai");
2231 assert_eq!(parsed.tool_timeout_seconds, 90);
2232 }
2233
2234 #[test]
2235 fn test_load_from_dir_with_json_project_config() {
2236 let _guard = EnvGuard::new(&[
2237 "OXI_MODEL",
2238 "OXI_PROVIDER",
2239 "OXI_THEME",
2240 "OXI_TOOL_TIMEOUT",
2241 "OXI_TEMPERATURE",
2242 "OXI_MAX_TOKENS",
2243 "OXI_SESSION_DIR",
2244 "OXI_STREAM",
2245 "OXI_EXTENSIONS_ENABLED",
2246 ]);
2247 let tmp = tempfile::tempdir().unwrap();
2248 let oxi_dir = tmp.path().join(".oxi");
2249 fs::create_dir_all(&oxi_dir).unwrap();
2250 let settings_path = oxi_dir.join("settings.json");
2251 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
2253 fs::write(&settings_path, json_content).unwrap();
2254
2255 let settings = Settings::load_from(tmp.path()).unwrap();
2256 assert_eq!(
2258 settings.last_used_model,
2259 Some("gemini-2.0-flash".to_string())
2260 );
2261 assert_eq!(settings.last_used_provider, Some("google".to_string()));
2262 }
2263
2264 #[test]
2265 fn test_find_project_settings_json_priority() {
2266 let tmp = tempfile::tempdir().unwrap();
2267 let oxi_dir = tmp.path().join(".oxi");
2268 fs::create_dir_all(&oxi_dir).unwrap();
2269
2270 let json_path = oxi_dir.join("settings.json");
2272 let toml_path = oxi_dir.join("settings.toml");
2273 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2274 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2275
2276 let found = Settings::find_project_settings(tmp.path());
2278 assert!(found.is_some());
2279 assert_eq!(
2280 found.unwrap().file_name().unwrap().to_str().unwrap(),
2281 "settings.json"
2282 );
2283 }
2284
2285 #[test]
2286 fn test_find_project_settings_json_only() {
2287 let tmp = tempfile::tempdir().unwrap();
2288 let oxi_dir = tmp.path().join(".oxi");
2289 fs::create_dir_all(&oxi_dir).unwrap();
2290
2291 let json_path = oxi_dir.join("settings.json");
2292 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2293
2294 let found = Settings::find_project_settings(tmp.path());
2295 assert!(found.is_some());
2296 assert_eq!(
2297 found.unwrap().file_name().unwrap().to_str().unwrap(),
2298 "settings.json"
2299 );
2300 }
2301
2302 #[test]
2303 fn test_find_project_settings_toml_fallback() {
2304 let tmp = tempfile::tempdir().unwrap();
2305 let oxi_dir = tmp.path().join(".oxi");
2306 fs::create_dir_all(&oxi_dir).unwrap();
2307
2308 let toml_path = oxi_dir.join("settings.toml");
2309 fs::write(&toml_path, r#"theme = "test""#).unwrap();
2310
2311 let found = Settings::find_project_settings(tmp.path());
2312 assert!(found.is_some());
2313 assert_eq!(
2314 found.unwrap().file_name().unwrap().to_str().unwrap(),
2315 "settings.toml"
2316 );
2317 }
2318
2319 #[test]
2320 fn test_detect_format() {
2321 let json_path = PathBuf::from("/test/settings.json");
2322 let toml_path = PathBuf::from("/test/settings.toml");
2323 let unknown_path = PathBuf::from("/test/settings");
2324
2325 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2326 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2327 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2328 }
2330
2331 #[test]
2332 fn test_settings_format_extension() {
2333 assert_eq!(SettingsFormat::Json.extension(), "json");
2334 assert_eq!(SettingsFormat::Toml.extension(), "toml");
2335 }
2336
2337 #[test]
2338 fn test_layer_json_over_toml() {
2339 let tmp = tempfile::tempdir().unwrap();
2341 let oxi_dir = tmp.path().join(".oxi");
2342 fs::create_dir_all(&oxi_dir).unwrap();
2343
2344 let json_path = oxi_dir.join("settings.json");
2345 let toml_path = oxi_dir.join("settings.toml");
2346
2347 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2349 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2351
2352 let settings = Settings::load_from(tmp.path()).unwrap();
2354 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2355 }
2356
2357 #[test]
2358 fn test_mixed_format_loading() {
2359 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2361 let toml_content = r#"
2362last_used_model = "loaded-via-toml"
2363theme = "loaded-theme"
2364stream_responses = false
2365"#;
2366 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2367
2368 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2369 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2370 assert_eq!(merged.theme, "loaded-theme");
2371 assert!(!merged.stream_responses);
2372 }
2373
2374 #[test]
2375 fn test_merge_json_values() {
2376 let base = serde_json::json!({
2377 "version": 1,
2378 "theme": "default",
2379 "extensions": ["ext1"],
2380 "nested": {
2381 "a": 1,
2382 "b": 2
2383 }
2384 });
2385
2386 let override_ = serde_json::json!({
2387 "version": 2,
2388 "theme": "dark",
2389 "extensions": ["ext2"],
2390 "nested": {
2391 "b": 20,
2392 "c": 30
2393 }
2394 });
2395
2396 let merged = merge_json_values(base, override_);
2397
2398 assert_eq!(merged["version"], 2);
2399 assert_eq!(merged["theme"], "dark");
2400 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2402 assert_eq!(merged["nested"]["a"], 1);
2404 assert_eq!(merged["nested"]["b"], 20);
2405 assert_eq!(merged["nested"]["c"], 30);
2406 }
2407
2408 #[test]
2409 fn test_save_project_preserves_existing_format() {
2410 let tmp = tempfile::tempdir().unwrap();
2411 let oxi_dir = tmp.path().join(".oxi");
2412 fs::create_dir_all(&oxi_dir).unwrap();
2413
2414 let toml_path = oxi_dir.join("settings.toml");
2416 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2417
2418 let mut settings = Settings::default();
2419 settings.theme = "new-theme".to_string();
2420 settings.save_project(tmp.path()).unwrap();
2421
2422 let content = fs::read_to_string(&toml_path).unwrap();
2424 assert!(content.contains("new-theme"));
2425 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2426 }
2427
2428 #[test]
2429 fn test_save_project_creates_json_by_default() {
2430 let tmp = tempfile::tempdir().unwrap();
2431 let oxi_dir = tmp.path().join(".oxi");
2432 fs::create_dir_all(&oxi_dir).unwrap();
2433 let mut settings = Settings::default();
2436 settings.theme = "json-theme".to_string();
2437 settings.save_project(tmp.path()).unwrap();
2438
2439 let json_path = oxi_dir.join("settings.json");
2441 assert!(json_path.exists());
2442 let content = fs::read_to_string(&json_path).unwrap();
2443 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2444 assert!(content.contains("json-theme"));
2445 }
2446
2447 #[test]
2450 fn test_custom_provider_default_api() {
2451 use super::CustomProvider;
2452 let cp = CustomProvider {
2453 name: "test".to_string(),
2454 base_url: "https://api.test.com/v1".to_string(),
2455 api_key_env: "TEST_API_KEY".to_string(),
2456 api: super::default_custom_provider_api(),
2457 };
2458 assert_eq!(cp.api, "openai-completions");
2459 }
2460
2461 #[test]
2462 fn test_custom_provider_toml_deserialize() {
2463 let toml_content = r#"
2464[[custom_providers]]
2465name = "minimax"
2466base_url = "https://api.minimax.chat/v1"
2467api_key_env = "MINIMAX_API_KEY"
2468api = "openai-completions"
2469
2470[[custom_providers]]
2471name = "zai"
2472base_url = "https://api.z.ai/v1"
2473api_key_env = "ZAI_API_KEY"
2474api = "openai-responses"
2475"#;
2476 let settings: Settings = toml::from_str(toml_content).unwrap();
2477 assert_eq!(settings.custom_providers.len(), 2);
2478 assert_eq!(settings.custom_providers[0].name, "minimax");
2479 assert_eq!(
2480 settings.custom_providers[0].base_url,
2481 "https://api.minimax.chat/v1"
2482 );
2483 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2484 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2485 assert_eq!(settings.custom_providers[1].name, "zai");
2486 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2487 }
2488
2489 #[test]
2490 fn test_custom_provider_json_deserialize() {
2491 let json_content = r#"{
2492 "custom_providers": [
2493 {
2494 "name": "minimax",
2495 "base_url": "https://api.minimax.chat/v1",
2496 "api_key_env": "MINIMAX_API_KEY",
2497 "api": "openai-completions"
2498 }
2499 ]
2500 }"#;
2501 let settings: Settings = serde_json::from_str(json_content).unwrap();
2502 assert_eq!(settings.custom_providers.len(), 1);
2503 assert_eq!(settings.custom_providers[0].name, "minimax");
2504 }
2505
2506 #[test]
2507 fn test_custom_provider_toml_roundtrip() {
2508 let mut settings = Settings::default();
2509 settings.custom_providers.push(super::CustomProvider {
2510 name: "test".to_string(),
2511 base_url: "https://api.test.com/v1".to_string(),
2512 api_key_env: "TEST_API_KEY".to_string(),
2513 api: "openai-completions".to_string(),
2514 });
2515
2516 let toml_str = toml::to_string_pretty(&settings).unwrap();
2517 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2518 assert_eq!(parsed.custom_providers.len(), 1);
2519 assert_eq!(parsed.custom_providers[0].name, "test");
2520 assert_eq!(
2521 parsed.custom_providers[0].base_url,
2522 "https://api.test.com/v1"
2523 );
2524 }
2525
2526 #[test]
2527 fn test_custom_provider_defaults_empty() {
2528 let settings = Settings::default();
2529 assert!(settings.custom_providers.is_empty());
2530 }
2531
2532 #[test]
2533 fn test_custom_provider_layer_file() {
2534 let base = Settings::default();
2535
2536 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2537 let toml_content = r#"
2538[[custom_providers]]
2539name = "my-provider"
2540base_url = "https://api.my-provider.com/v1"
2541api_key_env = "MY_PROVIDER_API_KEY"
2542"#;
2543 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2544
2545 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2546 assert_eq!(merged.custom_providers.len(), 1);
2547 assert_eq!(merged.custom_providers[0].name, "my-provider");
2548 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2550 }
2551}