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