1use anyhow::{Context, Result};
13use oxi_tui::GlyphSet;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::env;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20const SETTINGS_VERSION: u32 = 8;
29
30pub const KNOWN_CHANNELS: &[(&str, &str)] = &[
41 ("response", "Your conversational responses to the user"),
42 (
43 "code_comment",
44 "Code comments you write (//, /* */, #, etc.)",
45 ),
46 (
47 "documentation",
48 "Documentation (markdown files, README, AGENTS.md, doc comments)",
49 ),
50 ("commit_message", "Git commit messages (subject + body)"),
51];
52
53pub const KNOWN_LANGS: &[(&str, &str)] = &[
64 ("auto", "Auto (match user)"),
65 ("en", "English"),
66 ("ko", "Korean (한국어)"),
67 ("ja", "Japanese (日本語)"),
68 ("zh", "Chinese (中文)"),
69 ("es", "Spanish"),
70 ("fr", "French"),
71 ("de", "German"),
72];
73
74#[allow(dead_code)]
77const ENV_PREFIX: &str = "OXI_";
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
81#[serde(rename_all = "snake_case")]
82pub enum ThinkingLevel {
83 #[default]
85 Off,
86 Minimal,
88 Low,
90 Medium,
92 High,
94 XHigh,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
104#[serde(rename_all = "snake_case")]
105pub enum EditFormat {
106 #[default]
108 Hashline,
109 StrReplace,
111}
112#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CustomProvider {
118 pub name: String,
120 pub base_url: String,
122 pub api_key_env: String,
124 #[serde(default = "default_custom_provider_api")]
126 pub api: String,
127}
128
129fn default_custom_provider_api() -> String {
130 "openai-completions".to_string()
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct Settings {
136 #[serde(default)]
139 pub version: u32,
140
141 #[serde(default = "default_thinking_level")]
144 pub thinking_level: ThinkingLevel,
145 #[serde(default = "default_theme")]
147 pub theme: String,
148
149 #[serde(default)]
156 pub glyph_set: GlyphSet,
157
158 #[serde(default, skip_serializing)]
160 pub default_model: Option<String>,
161
162 #[serde(default, skip_serializing)]
164 pub default_provider: Option<String>,
165
166 #[serde(default)]
169 pub last_used_model: Option<String>,
170
171 #[serde(default)]
173 pub last_used_provider: Option<String>,
174
175 pub max_tokens: Option<u32>,
177
178 pub temperature: Option<f32>,
180
181 pub default_temperature: Option<f64>,
183
184 pub max_response_tokens: Option<usize>,
186
187 #[serde(default = "default_session_history_size")]
190 pub session_history_size: usize,
191
192 pub session_dir: Option<PathBuf>,
194
195 #[serde(default = "default_true")]
198 pub stream_responses: bool,
199
200 #[serde(default = "default_true")]
202 pub extensions_enabled: bool,
203
204 #[serde(default = "default_true")]
206 pub auto_compaction: bool,
207
208 #[serde(default)]
211 pub disabled_tools: Vec<String>,
212
213 #[serde(default = "default_tool_timeout")]
216 pub tool_timeout_seconds: u64,
217
218 #[serde(default, alias = "questionnaire_timeout_secs")]
221 pub ask_timeout_secs: u64,
222
223 #[serde(default)]
226 pub extensions: Vec<String>,
227
228 #[serde(default)]
230 pub skills: Vec<String>,
231
232 #[serde(default)]
234 pub prompts: Vec<String>,
235
236 #[serde(default)]
238 pub themes: Vec<String>,
239
240 #[serde(default)]
243 pub custom_providers: Vec<CustomProvider>,
244
245 #[serde(default)]
250 pub dynamic_models: HashMap<String, Vec<String>>,
251
252 #[serde(default = "default_false")]
255 pub enable_routing: bool,
256
257 #[serde(default)]
259 pub router_profile: Option<String>,
260
261 #[serde(default = "default_true")]
263 pub prefer_cost_efficient: bool,
264
265 #[serde(default)]
267 pub fallback_chain: Vec<String>,
268
269 #[serde(default = "default_true")]
271 pub enable_fallback: bool,
272
273 #[serde(default)]
275 pub disable_fallback: bool,
276
277 #[serde(default = "default_circuit_failure_threshold")]
279 pub circuit_breaker_failure_threshold: u32,
280
281 #[serde(default = "default_circuit_open_duration_secs")]
283 pub circuit_breaker_open_duration_secs: u64,
284
285 #[serde(default)]
290 pub keybindings: HashMap<String, Vec<String>>,
291
292 #[serde(default)]
338 pub output_languages: HashMap<String, String>,
339
340 #[serde(default = "default_false")]
359 pub language_policy_enabled: bool,
360
361 #[serde(default)]
366 pub edit_format: EditFormat,
367
368 #[serde(default = "default_false")]
372 pub memory_enabled: bool,
373
374 #[serde(default)]
377 pub memory_db_path: Option<PathBuf>,
378
379 #[serde(default = "default_false")]
383 pub ttsr_enabled: bool,
384
385 #[serde(default = "default_ttsr_mode")]
387 pub ttsr_interrupt_mode: String,
388}
389
390fn default_theme() -> String {
391 "default".to_string()
392}
393
394fn default_thinking_level() -> ThinkingLevel {
395 ThinkingLevel::Medium
396}
397
398fn default_session_history_size() -> usize {
399 100
400}
401
402fn default_true() -> bool {
403 true
404}
405
406fn default_false() -> bool {
407 false
408}
409
410fn default_ttsr_mode() -> String {
411 "prose_only".to_string()
412}
413
414fn default_circuit_failure_threshold() -> u32 {
415 5
416}
417
418fn default_circuit_open_duration_secs() -> u64 {
419 30
420}
421
422fn default_tool_timeout() -> u64 {
423 120
424}
425
426impl Default for Settings {
427 fn default() -> Self {
428 Self {
429 version: SETTINGS_VERSION,
430 thinking_level: ThinkingLevel::Medium,
431 theme: default_theme(),
432 glyph_set: GlyphSet::default(),
433 last_used_model: None,
434 last_used_provider: None,
435 default_model: None,
436 default_provider: None,
437 max_tokens: None,
438 temperature: None,
439 default_temperature: None,
440 max_response_tokens: None,
441 session_history_size: default_session_history_size(),
442 session_dir: None,
443 stream_responses: true,
444 extensions_enabled: true,
445 auto_compaction: true,
446 disabled_tools: Vec::new(),
447 tool_timeout_seconds: default_tool_timeout(),
448 ask_timeout_secs: 0,
449 extensions: Vec::new(),
450 skills: Vec::new(),
451 prompts: Vec::new(),
452 themes: Vec::new(),
453 custom_providers: Vec::new(),
454 dynamic_models: HashMap::new(),
455 enable_routing: false,
457 router_profile: None,
458 prefer_cost_efficient: true,
459 fallback_chain: Vec::new(),
460 enable_fallback: true,
461 disable_fallback: false,
462 circuit_breaker_failure_threshold: 5,
463 circuit_breaker_open_duration_secs: 30,
464 keybindings: HashMap::new(),
465 output_languages: HashMap::new(),
466 language_policy_enabled: false,
467 edit_format: EditFormat::default(),
468 memory_enabled: false,
469 memory_db_path: None,
470 ttsr_enabled: false,
471 ttsr_interrupt_mode: default_ttsr_mode(),
472 }
473 }
474}
475
476impl Settings {
477 pub fn settings_dir() -> Result<PathBuf> {
481 let base = dirs::home_dir().context("Cannot determine home directory")?;
482 Ok(base.join(".oxi"))
483 }
484
485 pub fn settings_toml_path() -> Result<PathBuf> {
487 Ok(Self::settings_dir()?.join("settings.toml"))
488 }
489
490 pub fn settings_json_path() -> Result<PathBuf> {
492 Ok(Self::settings_dir()?.join("settings.json"))
493 }
494
495 pub fn settings_path() -> Result<PathBuf> {
502 let json_path = Self::settings_json_path()?;
503 let toml_path = Self::settings_toml_path()?;
504
505 if json_path.exists() && toml_path.exists() {
506 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
508 return Ok(json_path);
509 }
510
511 if json_path.exists() {
512 return Ok(json_path);
513 }
514
515 if toml_path.exists() {
516 return Ok(toml_path);
517 }
518
519 Ok(json_path)
521 }
522
523 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
528 let json_path = Self::settings_json_path()?;
529 let toml_path = Self::settings_toml_path()?;
530
531 let (primary, secondary) = if prefer_json {
532 (&json_path, &toml_path)
533 } else {
534 (&toml_path, &json_path)
535 };
536
537 if primary.exists() {
538 return Ok(primary.clone());
539 }
540
541 if secondary.exists() {
542 return Ok(secondary.clone());
543 }
544
545 Ok(primary.clone())
547 }
548
549 pub fn detect_format(path: &Path) -> SettingsFormat {
551 match path.extension().and_then(|e| e.to_str()) {
552 Some("json") => SettingsFormat::Json,
553 Some("toml") => SettingsFormat::Toml,
554 _ => SettingsFormat::Json, }
556 }
557
558 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
563 let mut dir = start_dir.to_path_buf();
564 loop {
565 let json_candidate = dir.join(".oxi").join("settings.json");
567 if json_candidate.exists() {
568 return Some(json_candidate);
569 }
570
571 let toml_candidate = dir.join(".oxi").join("settings.toml");
572 if toml_candidate.exists() {
573 return Some(toml_candidate);
574 }
575
576 if !dir.pop() {
577 return None;
578 }
579 }
580 }
581
582 pub fn effective_session_dir(&self) -> Result<PathBuf> {
586 if let Some(ref dir) = self.session_dir {
587 return Ok(dir.clone());
588 }
589 Ok(Self::settings_dir()?.join("sessions"))
590 }
591
592 pub fn load() -> Result<Self> {
610 Self::load_from_cwd()
611 }
612
613 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
615 let mut settings = Settings::default();
617
618 if let Ok(global_path) = Self::settings_path()
620 && global_path.exists()
621 {
622 settings = Self::layer_file(&settings, &global_path)?;
623 }
624
625 if let Some(project_path) = Self::find_project_settings(dir) {
627 settings = Self::layer_file(&settings, &project_path)?;
628 }
629
630 settings.apply_env();
632
633 settings = Self::migrate(settings)?;
635
636 settings.validate_output_languages();
638
639 Ok(settings)
640 }
641
642 fn validate_output_languages(&mut self) {
649 if self.output_languages.is_empty() {
650 return;
651 }
652 let known_langs: std::collections::HashSet<&str> =
653 KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
654
655 for (channel, lang) in &self.output_languages {
656 if !known_langs.contains(lang.as_str()) {
657 tracing::warn!(
658 "Unknown output_languages language code '{}' for channel '{}'. \
659 Keeping as-is (the model will likely understand).",
660 lang,
661 channel
662 );
663 }
664 }
665 }
666
667 pub fn load_from_cwd() -> Result<Self> {
669 let cwd = env::current_dir().context("Cannot determine current directory")?;
670 Self::load_from(&cwd)
671 }
672
673 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
679 let content = fs::read_to_string(path)
680 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
681
682 let format = Self::detect_format(path);
683 let overlay: serde_json::Value = match format {
684 SettingsFormat::Toml => {
685 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
686 format!("Failed to parse TOML settings from {}", path.display())
687 })?;
688 toml_value_to_json(toml_value)
690 }
691 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
692 format!("Failed to parse JSON settings from {}", path.display())
693 })?,
694 };
695
696 let base_json =
700 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
701
702 let merged = merge_json_values(base_json, overlay);
703 let result: Settings =
704 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
705
706 Ok(result)
707 }
708
709 #[allow(dead_code)]
735 pub fn apply_env(&mut self) {
736 }
740
741 #[allow(dead_code)]
747 pub fn from_env() -> Self {
748 Self::default()
749 }
750
751 pub fn save(&self) -> Result<()> {
758 let dir = Self::settings_dir()?;
759 let path = Self::settings_path()?;
760
761 if !dir.exists() {
762 fs::create_dir_all(&dir).with_context(|| {
763 format!("Failed to create settings directory {}", dir.display())
764 })?;
765 }
766
767 let format = Self::detect_format(&path);
768 let content = Self::serialize_for_format(self, format)?;
769
770 let tmp_path = path.with_extension("tmp");
772 fs::write(&tmp_path, &content)
773 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
774 fs::rename(&tmp_path, &path)
775 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
776
777 Ok(())
778 }
779
780 pub fn save_to(&self, path: &Path) -> Result<()> {
782 if let Some(parent) = path.parent()
783 && !parent.exists()
784 {
785 fs::create_dir_all(parent)
786 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
787 }
788
789 let format = Self::detect_format(path);
790 let content = Self::serialize_for_format(self, format)?;
791
792 let tmp_path = path.with_extension("tmp");
794 fs::write(&tmp_path, &content)
795 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
796 fs::rename(&tmp_path, path)
797 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
798
799 Ok(())
800 }
801
802 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
806 let dir = project_dir.join(".oxi");
807
808 if !dir.exists() {
809 fs::create_dir_all(&dir).with_context(|| {
810 format!(
811 "Failed to create project settings directory {}",
812 dir.display()
813 )
814 })?;
815 }
816
817 let json_path = dir.join("settings.json");
819 let toml_path = dir.join("settings.toml");
820
821 let path = if json_path.exists() {
822 &json_path
823 } else if toml_path.exists() {
824 &toml_path
825 } else {
826 &json_path
828 };
829
830 let format = Self::detect_format(path);
831 let content = Self::serialize_for_format(self, format)?;
832
833 let tmp_path = path.with_extension("tmp");
835 fs::write(&tmp_path, &content)
836 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
837 fs::rename(&tmp_path, path)
838 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
839
840 Ok(())
841 }
842
843 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
845 match format {
846 SettingsFormat::Toml => {
847 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
848 }
849 SettingsFormat::Json => serde_json::to_string_pretty(settings)
850 .context("Failed to serialize settings to JSON"),
851 }
852 }
853
854 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
856 match format {
857 SettingsFormat::Toml => {
858 toml::from_str(content).context("Failed to parse TOML settings")
859 }
860 SettingsFormat::Json => {
861 serde_json::from_str(content).context("Failed to parse JSON settings")
862 }
863 }
864 }
865
866 pub fn merge_cli(
879 &mut self,
880 model: Option<String>,
881 provider: Option<String>,
882 enable_routing: Option<bool>,
883 prefer_cost_efficient: Option<bool>,
884 fallback_chain: Option<Vec<String>>,
885 disable_fallback: Option<bool>,
886 ) {
887 if let Some(m) = model {
888 self.last_used_model = Some(m);
889 }
890 if let Some(p) = provider {
891 self.last_used_provider = Some(p);
892 }
893 if let Some(r) = enable_routing {
894 self.enable_routing = r;
895 }
896 if let Some(p) = prefer_cost_efficient {
897 self.prefer_cost_efficient = p;
898 }
899 if let Some(fc) = fallback_chain
900 && !fc.is_empty()
901 {
902 self.fallback_chain = fc;
903 }
904 if let Some(df) = disable_fallback {
905 self.disable_fallback = df;
906 if df {
908 self.enable_fallback = false;
909 }
910 }
911 }
912
913 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
916 cli_model.map(String::from).or_else(|| {
917 let model = self.last_used_model.as_ref()?;
922 if model.contains('/') {
923 Some(model.clone())
925 } else if let Some(ref provider) = self.last_used_provider {
926 Some(format!("{}/{}", provider, model))
928 } else {
929 Some(model.clone())
930 }
931 })
932 }
933
934 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
937 cli_provider
938 .map(String::from)
939 .or_else(|| self.last_used_provider.clone())
940 }
941
942 pub fn effective_temperature(&self) -> Option<f64> {
945 self.default_temperature
946 .or(self.temperature.map(|t| t as f64))
947 }
948
949 pub fn effective_max_tokens(&self) -> Option<usize> {
952 self.max_response_tokens
953 .or(self.max_tokens.map(|t| t as usize))
954 }
955
956 pub fn router_profile(&self) -> Option<&str> {
958 self.router_profile.as_deref()
959 }
960
961 pub fn save_last_used(model_id: &str) {
967 if let Ok(mut settings) = Self::load() {
968 if let Some((provider, model)) = model_id.split_once('/') {
969 settings.last_used_provider = Some(provider.to_string());
970 settings.last_used_model = Some(model.to_string());
971 } else {
972 settings.last_used_model = Some(model_id.to_string());
973 }
974 let _ = settings.save();
975 }
976 }
977
978 pub fn save_theme(&mut self, name: &str) -> Result<()> {
980 self.theme = name.to_string();
981 self.save()
982 }
983
984 pub fn get_theme_name(&self) -> String {
986 if self.theme.is_empty() || self.theme == "default" {
987 "oxi_dark".to_string()
988 } else {
989 self.theme.clone()
990 }
991 }
992
993 fn migrate(settings: Settings) -> Result<Settings> {
1007 let mut settings = settings;
1008
1009 match settings.version {
1010 SETTINGS_VERSION => {
1011 }
1013 0 => {
1014 if settings.tool_timeout_seconds == 0 {
1017 settings.tool_timeout_seconds = default_tool_timeout();
1018 }
1019 settings.version = SETTINGS_VERSION;
1020
1021 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1022 }
1023 1 | 2 => {
1024 settings.version = SETTINGS_VERSION;
1029 tracing::info!(
1030 "Migrated settings from version {} to {} (dynamic_models + output_languages + language_policy_enabled defaults applied)",
1031 settings.version,
1032 SETTINGS_VERSION
1033 );
1034 }
1035 3 => {
1036 if let Some(model) = settings.default_model.take() {
1038 if let Some((provider, model_name)) = model.split_once('/') {
1039 settings.last_used_provider = Some(provider.to_string());
1040 settings.last_used_model = Some(model_name.to_string());
1041 } else {
1042 settings.last_used_model = Some(model);
1043 }
1044 }
1045 settings.version = SETTINGS_VERSION;
1047 tracing::info!(
1048 "Migrated settings from version 3 to {} (default_model → last_used_model; output_languages + language_policy_enabled defaults)",
1049 SETTINGS_VERSION
1050 );
1051 }
1052 4 => {
1053 settings.version = SETTINGS_VERSION;
1057 tracing::info!(
1058 "Migrated settings from version 4 to {} (added output_languages + language_policy_enabled, both defaulted to off)",
1059 SETTINGS_VERSION
1060 );
1061 }
1062 5 => {
1063 settings.version = SETTINGS_VERSION;
1069 tracing::info!(
1070 "Migrated settings from version 5 to {} (added language_policy_enabled, defaulting to OFF — toggle ON in /settings to activate existing channels)",
1071 SETTINGS_VERSION
1072 );
1073 }
1074 6 => {
1075 settings.version = SETTINGS_VERSION;
1078 tracing::info!(
1079 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1080 SETTINGS_VERSION
1081 );
1082 }
1083 7 => {
1084 settings.version = SETTINGS_VERSION;
1087 tracing::info!(
1088 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1089 SETTINGS_VERSION
1090 );
1091 }
1092 v if v > SETTINGS_VERSION => {
1093 anyhow::bail!(
1095 "Settings version {} is newer than supported version {}. \
1096 Please update oxi.",
1097 v,
1098 SETTINGS_VERSION
1099 );
1100 }
1101 v => {
1102 tracing::warn!(
1104 "Unknown settings version {}, attempting migration to {}",
1105 v,
1106 SETTINGS_VERSION
1107 );
1108 settings.version = SETTINGS_VERSION;
1109 }
1110 }
1111
1112 Ok(settings)
1113 }
1114}
1115
1116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1120pub enum SettingsFormat {
1121 #[default]
1123 Json,
1124 Toml,
1126}
1127
1128impl SettingsFormat {
1129 pub fn extension(&self) -> &'static str {
1131 match self {
1132 SettingsFormat::Json => "json",
1133 SettingsFormat::Toml => "toml",
1134 }
1135 }
1136}
1137
1138fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1142 match toml {
1143 toml::Value::String(s) => serde_json::Value::String(s),
1144 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1145 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1146 .map(serde_json::Value::Number)
1147 .unwrap_or(serde_json::Value::Null),
1148 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1149 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1150 toml::Value::Array(arr) => {
1151 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1152 }
1153 toml::Value::Table(table) => {
1154 let obj = table
1155 .into_iter()
1156 .map(|(k, v)| (k, toml_value_to_json(v)))
1157 .collect();
1158 serde_json::Value::Object(obj)
1159 }
1160 }
1161}
1162
1163fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1165 match (base, override_) {
1166 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1168 let mut result = base_map;
1169 for (key, override_value) in override_map {
1170 let base_value = result.remove(&key);
1171 let merged = match base_value {
1172 Some(base_v) => merge_json_values(base_v, override_value),
1173 None => override_value,
1174 };
1175 result.insert(key, merged);
1176 }
1177 serde_json::Value::Object(result)
1178 }
1179 (_, override_) => override_,
1181 }
1182}
1183
1184pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1186 match s.to_lowercase().as_str() {
1187 "off" | "none" => Some(ThinkingLevel::Off),
1188 "minimal" => Some(ThinkingLevel::Minimal),
1189 "low" => Some(ThinkingLevel::Low),
1190 "medium" | "standard" => Some(ThinkingLevel::Medium),
1191 "high" | "thorough" => Some(ThinkingLevel::High),
1192 "xhigh" => Some(ThinkingLevel::XHigh),
1193 _ => None,
1194 }
1195}
1196
1197#[allow(dead_code)]
1199fn parse_boolish(s: &str) -> Result<bool> {
1200 match s.to_lowercase().as_str() {
1201 "true" | "1" | "yes" | "on" => Ok(true),
1202 "false" | "0" | "no" | "off" => Ok(false),
1203 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1204 }
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210 use std::io::Write as IoWrite;
1211 use std::sync::Mutex;
1212
1213 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1216
1217 struct EnvGuard {
1220 saved: Vec<(String, Option<String>)>,
1221 }
1222
1223 impl EnvGuard {
1224 fn new(vars: &[&str]) -> Self {
1225 let saved = vars
1226 .iter()
1227 .map(|&name| {
1228 let old = env::var(name).ok();
1229 unsafe { env::remove_var(name) };
1231 (name.to_string(), old)
1232 })
1233 .collect();
1234 Self { saved }
1235 }
1236 }
1237
1238 impl Drop for EnvGuard {
1239 fn drop(&mut self) {
1240 for (name, old) in self.saved.drain(..) {
1241 match old {
1242 Some(val) => unsafe { env::set_var(&name, val) },
1244 None => unsafe { env::remove_var(&name) },
1245 }
1246 }
1247 }
1248 }
1249
1250 #[test]
1253 fn test_default_settings() {
1254 let settings = Settings::default();
1255 assert_eq!(settings.version, SETTINGS_VERSION);
1256 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1257 assert_eq!(settings.theme, "default");
1258 assert!(settings.last_used_model.is_none());
1259 assert!(settings.last_used_provider.is_none());
1260 assert!(settings.extensions_enabled);
1261 assert!(settings.auto_compaction);
1262 assert_eq!(settings.tool_timeout_seconds, 120);
1263 assert!(settings.stream_responses);
1264 }
1265
1266 #[test]
1267 fn test_merge_cli() {
1268 let mut settings = Settings::default();
1269 settings.last_used_model = Some("gpt-4o".to_string());
1270
1271 settings.merge_cli(Some("claude".to_string()), None, None, None, None, None);
1272 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1273
1274 settings.merge_cli(None, Some("google".to_string()), None, None, None, None);
1275 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1276
1277 settings.merge_cli(
1279 None,
1280 None,
1281 Some(true),
1282 Some(false),
1283 Some(vec!["openai/gpt-4o".to_string()]),
1284 Some(false),
1285 );
1286 assert!(settings.enable_routing);
1287 assert!(!settings.prefer_cost_efficient);
1288 assert_eq!(settings.fallback_chain, vec!["openai/gpt-4o"]);
1289 assert!(!settings.disable_fallback);
1290
1291 let mut settings2 = Settings::default();
1293 settings2.merge_cli(None, None, None, None, None, Some(true));
1294 assert!(settings2.disable_fallback);
1295 assert!(!settings2.enable_fallback);
1296 }
1297
1298 #[test]
1301 fn test_layer_file_overrides() {
1302 let base = Settings::default();
1303
1304 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1305 let toml_content = r#"
1306last_used_model = "openai/gpt-4o"
1307theme = "dracula"
1308"#;
1309 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1310
1311 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1312 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1313 assert_eq!(merged.theme, "dracula");
1314 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1316 assert!(merged.extensions_enabled);
1317 }
1318
1319 #[test]
1320 fn test_layer_file_preserves_unset() {
1321 let mut base = Settings::default();
1322 base.last_used_provider = Some("deepseek".to_string());
1323
1324 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1325 let toml_content = "theme = \"monokai\"\n";
1327 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1328
1329 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1330 assert_eq!(merged.theme, "monokai");
1331 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1332 }
1333
1334 #[test]
1335 fn test_load_from_dir_with_project_config() {
1336 let _guard = EnvGuard::new(&[
1337 "OXI_MODEL",
1338 "OXI_PROVIDER",
1339 "OXI_THEME",
1340 "OXI_TOOL_TIMEOUT",
1341 "OXI_TEMPERATURE",
1342 "OXI_MAX_TOKENS",
1343 "OXI_SESSION_DIR",
1344 "OXI_STREAM",
1345 "OXI_EXTENSIONS_ENABLED",
1346 ]);
1347 let tmp = tempfile::tempdir().unwrap();
1348 let oxi_dir = tmp.path().join(".oxi");
1349 fs::create_dir_all(&oxi_dir).unwrap();
1350 let settings_path = oxi_dir.join("settings.toml");
1351 fs::write(
1353 &settings_path,
1354 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1355 )
1356 .unwrap();
1357
1358 let settings = Settings::load_from(tmp.path()).unwrap();
1359 assert_eq!(
1361 settings.last_used_model,
1362 Some("gemini-2.0-flash".to_string())
1363 );
1364 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1365 }
1366
1367 #[test]
1368 fn test_load_from_dir_no_config() {
1369 let _guard = EnvGuard::new(&[
1371 "OXI_MODEL",
1372 "OXI_PROVIDER",
1373 "OXI_THEME",
1374 "OXI_TOOL_TIMEOUT",
1375 "OXI_TEMPERATURE",
1376 "OXI_MAX_TOKENS",
1377 "OXI_SESSION_DIR",
1378 "OXI_STREAM",
1379 "OXI_EXTENSIONS_ENABLED",
1380 ]);
1381 let tmp = tempfile::tempdir().unwrap();
1382 let settings = Settings::load_from(tmp.path()).unwrap();
1383 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1385 }
1386
1387 #[test]
1390 fn test_from_env() {
1391 let _guard = EnvGuard::new(&[
1394 "OXI_MODEL",
1396 "OXI_THEME",
1397 "OXI_TOOL_TIMEOUT",
1398 "OXI_PROVIDER",
1399 "OXI_DEFAULT_MODEL",
1400 ]);
1401
1402 let settings = Settings::from_env();
1403 assert_eq!(settings.last_used_model, None);
1405 assert_eq!(settings.theme, "default");
1406 assert_eq!(settings.tool_timeout_seconds, 120);
1407 }
1408
1409 #[test]
1410 fn test_apply_env_boolish() {
1411 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
1414 unsafe { env::set_var("OXI_STREAM", "false") };
1415 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1416
1417 let mut settings = Settings::default();
1418 settings.apply_env();
1419 assert!(settings.stream_responses); assert!(settings.extensions_enabled); }
1423
1424 #[test]
1425 fn test_apply_env_temperature() {
1426 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1428 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1429
1430 let mut settings = Settings::default();
1431 settings.apply_env();
1432 assert_eq!(settings.default_temperature, None);
1434 }
1435
1436 #[test]
1437 fn test_env_does_not_override_when_unset() {
1438 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1439 let settings = Settings::from_env();
1440 assert!(settings.last_used_model.is_none());
1441 assert!(settings.last_used_provider.is_none());
1442 }
1443
1444 #[test]
1445 fn test_parse_thinking_level() {
1446 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1447 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1448 assert_eq!(
1449 parse_thinking_level("MINIMAL"),
1450 Some(ThinkingLevel::Minimal)
1451 );
1452 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1453 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1454 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1455 assert_eq!(
1456 parse_thinking_level("Standard"),
1457 Some(ThinkingLevel::Medium)
1458 );
1459 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1460 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1461 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1462 assert_eq!(parse_thinking_level("invalid"), None);
1463 }
1464
1465 #[test]
1466 fn test_parse_boolish() {
1467 assert!(parse_boolish("true").unwrap());
1468 assert!(parse_boolish("1").unwrap());
1469 assert!(parse_boolish("yes").unwrap());
1470 assert!(parse_boolish("ON").unwrap());
1471 assert!(!parse_boolish("false").unwrap());
1472 assert!(!parse_boolish("0").unwrap());
1473 assert!(!parse_boolish("no").unwrap());
1474 assert!(!parse_boolish("OFF").unwrap());
1475 assert!(parse_boolish("maybe").is_err());
1476 }
1477
1478 #[test]
1481 fn test_effective_model_returns_last_used() {
1482 let mut settings = Settings::default();
1483 settings.last_used_model = Some("openai/gpt-4o".to_string());
1484 assert_eq!(
1485 settings.effective_model(None),
1486 Some("openai/gpt-4o".to_string())
1487 );
1488 }
1489
1490 #[test]
1491 fn test_effective_model_cli_overrides() {
1492 let mut settings = Settings::default();
1493 settings.last_used_model = Some("openai/gpt-4o".to_string());
1494 assert_eq!(
1495 settings.effective_model(Some("anthropic/claude-3")),
1496 Some("anthropic/claude-3".to_string())
1497 );
1498 }
1499
1500 #[test]
1501 fn test_effective_model_none_when_unset() {
1502 let settings = Settings::default();
1503 assert_eq!(settings.effective_model(None), None);
1504 }
1505
1506 #[test]
1507 fn test_effective_model_falls_back_to_last_used() {
1508 let mut settings = Settings::default();
1509 settings.last_used_model = Some("anthropic/claude-3".to_string());
1510 assert_eq!(
1511 settings.effective_model(None),
1512 Some("anthropic/claude-3".to_string())
1513 );
1514 }
1515
1516 #[test]
1517 fn test_effective_model_returns_none_when_nothing_set() {
1518 let settings = Settings::default();
1519 assert_eq!(settings.effective_model(None), None);
1520 }
1521
1522 #[test]
1523 fn test_effective_temperature_prefers_f64() {
1524 let mut settings = Settings::default();
1525 settings.temperature = Some(0.5);
1526 settings.default_temperature = Some(0.7);
1527 assert_eq!(settings.effective_temperature(), Some(0.7));
1528 }
1529
1530 #[test]
1531 fn test_effective_temperature_falls_back_to_f32() {
1532 let mut settings = Settings::default();
1533 settings.temperature = Some(0.5);
1534 assert_eq!(settings.effective_temperature(), Some(0.5));
1535 }
1536
1537 #[test]
1538 fn test_effective_max_tokens_prefers_usize() {
1539 let mut settings = Settings::default();
1540 settings.max_tokens = Some(1024);
1541 settings.max_response_tokens = Some(4096);
1542 assert_eq!(settings.effective_max_tokens(), Some(4096));
1543 }
1544
1545 #[test]
1546 fn test_effective_max_tokens_falls_back_to_u32() {
1547 let mut settings = Settings::default();
1548 settings.max_tokens = Some(1024);
1549 assert_eq!(settings.effective_max_tokens(), Some(1024));
1550 }
1551
1552 #[test]
1555 fn test_effective_session_dir_default() {
1556 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1557 let settings = Settings::default();
1558 let dir = settings.effective_session_dir().unwrap();
1559 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1560 }
1561
1562 #[test]
1563 fn test_effective_session_dir_from_field() {
1564 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1565 let mut settings = Settings::default();
1566 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1567 assert_eq!(
1568 settings.effective_session_dir().unwrap(),
1569 PathBuf::from("/tmp/oxi-sessions")
1570 );
1571 }
1572
1573 #[test]
1574 fn test_effective_session_dir_env_disabled() {
1575 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1578 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1579 let settings = Settings::default();
1580 let dir = settings.effective_session_dir().unwrap();
1582 assert!(
1583 dir.ends_with("sessions"),
1584 "expected default sessions dir, got: {:?}",
1585 dir
1586 );
1587 }
1588
1589 #[test]
1592 fn test_migration_v0_to_v1() {
1593 let mut settings = Settings::default();
1594 settings.version = 0;
1595 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1598 assert_eq!(migrated.version, SETTINGS_VERSION);
1599 assert_eq!(migrated.tool_timeout_seconds, 120);
1600 }
1601
1602 #[test]
1603 fn test_migration_already_current() {
1604 let settings = Settings::default();
1605 let migrated = Settings::migrate(settings).unwrap();
1606 assert_eq!(migrated.version, SETTINGS_VERSION);
1607 }
1608
1609 #[test]
1610 fn test_migration_v3_to_v4_splits_model() {
1611 let mut settings = Settings::default();
1612 settings.version = 3;
1613 settings.default_model = Some("openai/gpt-4o".to_string());
1614 settings.default_provider = None;
1615
1616 let migrated = Settings::migrate(settings).unwrap();
1617 assert_eq!(migrated.version, SETTINGS_VERSION);
1618 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1619 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1620 }
1621
1622 #[test]
1623 fn test_migration_v3_no_slash_keeps_model() {
1624 let mut settings = Settings::default();
1625 settings.version = 3;
1626 settings.default_model = Some("bare-model-name".to_string());
1627
1628 let migrated = Settings::migrate(settings).unwrap();
1629 assert_eq!(migrated.version, SETTINGS_VERSION);
1630 assert_eq!(
1631 migrated.last_used_model,
1632 Some("bare-model-name".to_string())
1633 );
1634 }
1635
1636 #[test]
1637 fn test_migration_future_version_fails() {
1638 let mut settings = Settings::default();
1639 settings.version = 9999;
1640 assert!(Settings::migrate(settings).is_err());
1641 }
1642
1643 #[test]
1646 fn test_default_output_languages_is_empty() {
1647 let settings = Settings::default();
1648 assert!(
1649 settings.output_languages.is_empty(),
1650 "all channels should default to auto (empty map)"
1651 );
1652 }
1653
1654 #[test]
1655 fn test_migration_v4_to_v5_preserves_existing_output_languages() {
1656 let mut settings = Settings::default();
1657 settings.version = 4;
1658 settings
1659 .output_languages
1660 .insert("response".to_string(), "ko".to_string());
1661 settings
1662 .output_languages
1663 .insert("commit_message".to_string(), "en".to_string());
1664
1665 let migrated = Settings::migrate(settings).unwrap();
1666 assert_eq!(migrated.version, SETTINGS_VERSION);
1667 assert_eq!(
1668 migrated.output_languages.get("response"),
1669 Some(&"ko".to_string())
1670 );
1671 assert_eq!(
1672 migrated.output_languages.get("commit_message"),
1673 Some(&"en".to_string())
1674 );
1675 }
1676
1677 #[test]
1678 fn test_migration_v4_to_v5_creates_empty_if_missing() {
1679 let mut settings = Settings::default();
1683 settings.version = 4;
1684 assert!(settings.output_languages.is_empty());
1685
1686 let migrated = Settings::migrate(settings).unwrap();
1687 assert_eq!(migrated.version, SETTINGS_VERSION);
1688 assert!(migrated.output_languages.is_empty());
1689 }
1690
1691 #[test]
1692 fn test_validate_keeps_user_defined_channel() {
1693 let mut settings = Settings::default();
1698 settings
1699 .output_languages
1700 .insert("pr_description".to_string(), "en".to_string()); settings
1702 .output_languages
1703 .insert("response".to_string(), "ko".to_string()); settings.validate_output_languages();
1706
1707 assert!(settings.output_languages.contains_key("pr_description"));
1708 assert!(settings.output_languages.contains_key("response"));
1709 assert_eq!(
1710 settings.output_languages.get("pr_description"),
1711 Some(&"en".to_string())
1712 );
1713 assert_eq!(
1714 settings.output_languages.get("response"),
1715 Some(&"ko".to_string())
1716 );
1717 }
1718
1719 #[test]
1720 fn test_validate_keeps_unknown_lang_with_warning() {
1721 let mut settings = Settings::default();
1722 settings
1723 .output_languages
1724 .insert("response".to_string(), "klingon".to_string()); settings
1726 .output_languages
1727 .insert("commit_message".to_string(), "en".to_string()); settings.validate_output_languages();
1730
1731 assert_eq!(
1734 settings.output_languages.get("response"),
1735 Some(&"klingon".to_string())
1736 );
1737 assert_eq!(
1738 settings.output_languages.get("commit_message"),
1739 Some(&"en".to_string())
1740 );
1741 }
1742
1743 #[test]
1744 fn test_known_channels_table_includes_core_four() {
1745 let keys: Vec<&str> = KNOWN_CHANNELS.iter().map(|(k, _)| *k).collect();
1746 assert!(keys.contains(&"response"));
1747 assert!(keys.contains(&"code_comment"));
1748 assert!(keys.contains(&"documentation"));
1749 assert!(keys.contains(&"commit_message"));
1750 }
1751
1752 #[test]
1753 fn test_known_langs_table_includes_auto_and_english() {
1754 let codes: Vec<&str> = KNOWN_LANGS.iter().map(|(k, _)| *k).collect();
1755 assert!(codes.contains(&"auto"));
1756 assert!(codes.contains(&"en"));
1757 }
1758
1759 #[test]
1760 fn test_default_language_policy_enabled_is_false() {
1761 let settings = Settings::default();
1763 assert!(
1764 !settings.language_policy_enabled,
1765 "language_policy_enabled must default to false (opt-in)"
1766 );
1767 }
1768
1769 #[test]
1770 fn test_migration_v5_to_v6_defaults_master_toggle_to_off() {
1771 let mut settings = Settings::default();
1775 settings.version = 5;
1776 settings
1777 .output_languages
1778 .insert("response".to_string(), "ko".to_string());
1779 settings
1780 .output_languages
1781 .insert("commit_message".to_string(), "en".to_string());
1782
1783 let migrated = Settings::migrate(settings).unwrap();
1784 assert_eq!(migrated.version, SETTINGS_VERSION);
1785 assert!(
1786 !migrated.language_policy_enabled,
1787 "v5 → v6 migration must default language_policy_enabled to false"
1788 );
1789 assert_eq!(
1791 migrated.output_languages.get("response"),
1792 Some(&"ko".to_string())
1793 );
1794 assert_eq!(
1795 migrated.output_languages.get("commit_message"),
1796 Some(&"en".to_string())
1797 );
1798 }
1799
1800 #[test]
1801 fn test_default_glyph_set_is_unicode() {
1802 let settings = Settings::default();
1803 assert_eq!(
1804 settings.glyph_set,
1805 GlyphSet::Unicode,
1806 "glyph_set must default to Unicode"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1812 let mut settings = Settings::default();
1815 settings.version = 7;
1816 settings.glyph_set = GlyphSet::default();
1818
1819 let migrated = Settings::migrate(settings).unwrap();
1820 assert_eq!(migrated.version, SETTINGS_VERSION);
1821 assert_eq!(
1822 migrated.glyph_set,
1823 GlyphSet::Unicode,
1824 "v7 → v8 migration must default glyph_set to unicode"
1825 );
1826 }
1827
1828 #[test]
1829 fn test_glyph_set_persists_through_roundtrip() {
1830 let mut original = Settings::default();
1834 original.glyph_set = GlyphSet::Nerd;
1835 let content = toml::to_string_pretty(&original).unwrap();
1836 assert!(
1837 content.contains("glyph_set = \"nerd\""),
1838 "nerd preset must serialize to snake_case; got:\n{content}"
1839 );
1840 let loaded: Settings = toml::from_str(&content).unwrap();
1841 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1842 original.glyph_set = GlyphSet::Unicode;
1844 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1845 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1846 }
1847
1848 #[test]
1849 fn test_save_and_load_roundtrip_preserves_language_policy_enabled() {
1850 let tmp = tempfile::tempdir().unwrap();
1851 let settings_path = tmp.path().join("settings.toml");
1852
1853 let mut original = Settings::default();
1854 original.language_policy_enabled = true;
1855 original
1856 .output_languages
1857 .insert("response".to_string(), "ko".to_string());
1858
1859 let content = toml::to_string_pretty(&original).unwrap();
1860 fs::write(&settings_path, &content).unwrap();
1861
1862 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1863 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1864
1865 assert!(loaded.language_policy_enabled);
1866 assert_eq!(
1867 loaded.output_languages.get("response"),
1868 Some(&"ko".to_string())
1869 );
1870 }
1871
1872 #[test]
1873 fn test_save_and_load_roundtrip_preserves_output_languages() {
1874 let tmp = tempfile::tempdir().unwrap();
1875 let settings_path = tmp.path().join("settings.toml");
1876
1877 let mut original = Settings::default();
1878 original
1879 .output_languages
1880 .insert("response".to_string(), "ko".to_string());
1881 original
1882 .output_languages
1883 .insert("commit_message".to_string(), "en".to_string());
1884
1885 let content = toml::to_string_pretty(&original).unwrap();
1886 fs::write(&settings_path, &content).unwrap();
1887
1888 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1889 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1890
1891 assert_eq!(
1892 loaded.output_languages.get("response"),
1893 Some(&"ko".to_string())
1894 );
1895 assert_eq!(
1896 loaded.output_languages.get("commit_message"),
1897 Some(&"en".to_string())
1898 );
1899 }
1900
1901 #[test]
1904 fn test_save_and_load_roundtrip() {
1905 let tmp = tempfile::tempdir().unwrap();
1906 let settings_path = tmp.path().join("settings.toml");
1907
1908 let mut original = Settings::default();
1909 original.last_used_model = Some("gpt-4o".to_string());
1910 original.last_used_provider = Some("openai".to_string());
1911 original.theme = "dracula".to_string();
1912 original.tool_timeout_seconds = 60;
1913
1914 let content = toml::to_string_pretty(&original).unwrap();
1916 fs::write(&settings_path, &content).unwrap();
1917
1918 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1920 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1921
1922 assert_eq!(loaded.last_used_model, original.last_used_model);
1923 assert_eq!(loaded.theme, original.theme);
1924 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1925 }
1926
1927 #[test]
1928 fn test_toml_roundtrip_preserves_new_fields() {
1929 let mut settings = Settings::default();
1930 settings.default_temperature = Some(0.8);
1931 settings.max_response_tokens = Some(8192);
1932 settings.auto_compaction = false;
1933 settings.extensions_enabled = false;
1934 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1935
1936 let toml_str = toml::to_string_pretty(&settings).unwrap();
1937 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1938
1939 assert_eq!(parsed.default_temperature, Some(0.8));
1940 assert_eq!(parsed.max_response_tokens, Some(8192));
1941 assert!(!parsed.auto_compaction);
1942 assert!(!parsed.extensions_enabled);
1943 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1944 }
1945
1946 #[test]
1949 fn test_json_roundtrip() {
1950 let mut settings = Settings::default();
1951 settings.last_used_model = Some("gpt-4o".to_string());
1952 settings.last_used_provider = Some("openai".to_string());
1953 settings.theme = "dracula".to_string();
1954 settings.tool_timeout_seconds = 60;
1955 settings.default_temperature = Some(0.8);
1956 settings.max_response_tokens = Some(8192);
1957
1958 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1959 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1960
1961 assert_eq!(parsed.last_used_model, settings.last_used_model);
1962 assert_eq!(parsed.theme, settings.theme);
1963 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1964 assert_eq!(parsed.default_temperature, settings.default_temperature);
1965 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1966 }
1967
1968 #[test]
1969 fn test_json_serialize_for_format() {
1970 let mut settings = Settings::default();
1971 settings.last_used_model = Some("claude-3".to_string());
1972 settings.last_used_provider = Some("anthropic".to_string());
1973 settings.thinking_level = ThinkingLevel::Minimal;
1974
1975 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1976 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1977
1978 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1979 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1980 }
1981
1982 #[test]
1983 fn test_toml_serialize_for_format() {
1984 let mut settings = Settings::default();
1985 settings.last_used_model = Some("gemini-pro".to_string());
1986 settings.last_used_provider = Some("google".to_string());
1987 settings.thinking_level = ThinkingLevel::High;
1988
1989 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1990 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1991
1992 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1993 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1994 }
1995
1996 #[test]
1997 fn test_parse_from_str_json() {
1998 let json_content = r#"{
1999 "last_used_model": "gpt-4",
2000 "last_used_provider": "openai",
2001 "theme": "nord",
2002 "tool_timeout_seconds": 90
2003 }"#;
2004
2005 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
2006 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
2007 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
2008 assert_eq!(settings.theme, "nord");
2009 assert_eq!(settings.tool_timeout_seconds, 90);
2010 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2012 assert!(settings.extensions_enabled);
2013 }
2014
2015 #[test]
2016 fn test_parse_from_str_toml() {
2017 let toml_content = r#"
2018last_used_model = "claude-opus"
2019last_used_provider = "anthropic"
2020theme = "monokai"
2021tool_timeout_seconds = 45
2022"#;
2023
2024 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
2025 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
2026 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
2027 assert_eq!(settings.theme, "monokai");
2028 assert_eq!(settings.tool_timeout_seconds, 45);
2029 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
2030 }
2031
2032 #[test]
2033 fn test_layer_file_json() {
2034 let base = Settings::default();
2035
2036 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2037 let json_content = r#"{
2038 "last_used_model": "gpt-4o",
2039 "last_used_provider": "openai",
2040 "theme": "dracula",
2041 "auto_compaction": false
2042 }"#;
2043 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2044
2045 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2046 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
2047 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
2048 assert_eq!(merged.theme, "dracula");
2049 assert!(!merged.auto_compaction);
2050 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
2052 assert!(merged.extensions_enabled);
2053 assert_eq!(merged.tool_timeout_seconds, 120);
2054 }
2055
2056 #[test]
2057 fn test_layer_file_json_preserves_unset() {
2058 let mut base = Settings::default();
2059 base.last_used_provider = Some("deepseek".to_string());
2060
2061 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
2062 let json_content = r#"{ "theme": "nord" }"#;
2063 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
2064
2065 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2066 assert_eq!(merged.theme, "nord");
2067 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
2068 }
2069
2070 #[test]
2071 fn test_save_to_json() {
2072 let tmp = tempfile::tempdir().unwrap();
2073 let settings_path = tmp.path().join("settings.json");
2074
2075 let mut settings = Settings::default();
2076 settings.last_used_model = Some("gpt-4o".to_string());
2077 settings.last_used_provider = Some("openai".to_string());
2078 settings.theme = "dracula".to_string();
2079 settings.tool_timeout_seconds = 60;
2080
2081 settings.save_to(&settings_path).unwrap();
2082
2083 let content = fs::read_to_string(&settings_path).unwrap();
2085 let parsed: Settings = serde_json::from_str(&content).unwrap();
2086 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
2087 assert_eq!(parsed.theme, "dracula");
2088 assert_eq!(parsed.tool_timeout_seconds, 60);
2089 }
2090
2091 #[test]
2092 fn test_save_to_toml() {
2093 let tmp = tempfile::tempdir().unwrap();
2094 let settings_path = tmp.path().join("settings.toml");
2095
2096 let mut settings = Settings::default();
2097 settings.last_used_model = Some("gemini-pro".to_string());
2098 settings.last_used_provider = Some("google".to_string());
2099 settings.theme = "monokai".to_string();
2100 settings.tool_timeout_seconds = 90;
2101
2102 settings.save_to(&settings_path).unwrap();
2103
2104 let content = fs::read_to_string(&settings_path).unwrap();
2106 let parsed: Settings = toml::from_str(&content).unwrap();
2107 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2108 assert_eq!(parsed.theme, "monokai");
2109 assert_eq!(parsed.tool_timeout_seconds, 90);
2110 }
2111
2112 #[test]
2113 fn test_load_from_dir_with_json_project_config() {
2114 let _guard = EnvGuard::new(&[
2115 "OXI_MODEL",
2116 "OXI_PROVIDER",
2117 "OXI_THEME",
2118 "OXI_TOOL_TIMEOUT",
2119 "OXI_TEMPERATURE",
2120 "OXI_MAX_TOKENS",
2121 "OXI_SESSION_DIR",
2122 "OXI_STREAM",
2123 "OXI_EXTENSIONS_ENABLED",
2124 ]);
2125 let tmp = tempfile::tempdir().unwrap();
2126 let oxi_dir = tmp.path().join(".oxi");
2127 fs::create_dir_all(&oxi_dir).unwrap();
2128 let settings_path = oxi_dir.join("settings.json");
2129 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
2131 fs::write(&settings_path, json_content).unwrap();
2132
2133 let settings = Settings::load_from(tmp.path()).unwrap();
2134 assert_eq!(
2136 settings.last_used_model,
2137 Some("gemini-2.0-flash".to_string())
2138 );
2139 assert_eq!(settings.last_used_provider, Some("google".to_string()));
2140 }
2141
2142 #[test]
2143 fn test_find_project_settings_json_priority() {
2144 let tmp = tempfile::tempdir().unwrap();
2145 let oxi_dir = tmp.path().join(".oxi");
2146 fs::create_dir_all(&oxi_dir).unwrap();
2147
2148 let json_path = oxi_dir.join("settings.json");
2150 let toml_path = oxi_dir.join("settings.toml");
2151 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2152 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2153
2154 let found = Settings::find_project_settings(tmp.path());
2156 assert!(found.is_some());
2157 assert_eq!(
2158 found.unwrap().file_name().unwrap().to_str().unwrap(),
2159 "settings.json"
2160 );
2161 }
2162
2163 #[test]
2164 fn test_find_project_settings_json_only() {
2165 let tmp = tempfile::tempdir().unwrap();
2166 let oxi_dir = tmp.path().join(".oxi");
2167 fs::create_dir_all(&oxi_dir).unwrap();
2168
2169 let json_path = oxi_dir.join("settings.json");
2170 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2171
2172 let found = Settings::find_project_settings(tmp.path());
2173 assert!(found.is_some());
2174 assert_eq!(
2175 found.unwrap().file_name().unwrap().to_str().unwrap(),
2176 "settings.json"
2177 );
2178 }
2179
2180 #[test]
2181 fn test_find_project_settings_toml_fallback() {
2182 let tmp = tempfile::tempdir().unwrap();
2183 let oxi_dir = tmp.path().join(".oxi");
2184 fs::create_dir_all(&oxi_dir).unwrap();
2185
2186 let toml_path = oxi_dir.join("settings.toml");
2187 fs::write(&toml_path, r#"theme = "test""#).unwrap();
2188
2189 let found = Settings::find_project_settings(tmp.path());
2190 assert!(found.is_some());
2191 assert_eq!(
2192 found.unwrap().file_name().unwrap().to_str().unwrap(),
2193 "settings.toml"
2194 );
2195 }
2196
2197 #[test]
2198 fn test_detect_format() {
2199 let json_path = PathBuf::from("/test/settings.json");
2200 let toml_path = PathBuf::from("/test/settings.toml");
2201 let unknown_path = PathBuf::from("/test/settings");
2202
2203 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2204 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2205 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2206 }
2208
2209 #[test]
2210 fn test_settings_format_extension() {
2211 assert_eq!(SettingsFormat::Json.extension(), "json");
2212 assert_eq!(SettingsFormat::Toml.extension(), "toml");
2213 }
2214
2215 #[test]
2216 fn test_layer_json_over_toml() {
2217 let tmp = tempfile::tempdir().unwrap();
2219 let oxi_dir = tmp.path().join(".oxi");
2220 fs::create_dir_all(&oxi_dir).unwrap();
2221
2222 let json_path = oxi_dir.join("settings.json");
2223 let toml_path = oxi_dir.join("settings.toml");
2224
2225 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2227 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2229
2230 let settings = Settings::load_from(tmp.path()).unwrap();
2232 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2233 }
2234
2235 #[test]
2236 fn test_mixed_format_loading() {
2237 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2239 let toml_content = r#"
2240last_used_model = "loaded-via-toml"
2241theme = "loaded-theme"
2242stream_responses = false
2243"#;
2244 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2245
2246 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2247 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2248 assert_eq!(merged.theme, "loaded-theme");
2249 assert!(!merged.stream_responses);
2250 }
2251
2252 #[test]
2253 fn test_merge_json_values() {
2254 let base = serde_json::json!({
2255 "version": 1,
2256 "theme": "default",
2257 "extensions": ["ext1"],
2258 "nested": {
2259 "a": 1,
2260 "b": 2
2261 }
2262 });
2263
2264 let override_ = serde_json::json!({
2265 "version": 2,
2266 "theme": "dark",
2267 "extensions": ["ext2"],
2268 "nested": {
2269 "b": 20,
2270 "c": 30
2271 }
2272 });
2273
2274 let merged = merge_json_values(base, override_);
2275
2276 assert_eq!(merged["version"], 2);
2277 assert_eq!(merged["theme"], "dark");
2278 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2280 assert_eq!(merged["nested"]["a"], 1);
2282 assert_eq!(merged["nested"]["b"], 20);
2283 assert_eq!(merged["nested"]["c"], 30);
2284 }
2285
2286 #[test]
2287 fn test_save_project_preserves_existing_format() {
2288 let tmp = tempfile::tempdir().unwrap();
2289 let oxi_dir = tmp.path().join(".oxi");
2290 fs::create_dir_all(&oxi_dir).unwrap();
2291
2292 let toml_path = oxi_dir.join("settings.toml");
2294 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2295
2296 let mut settings = Settings::default();
2297 settings.theme = "new-theme".to_string();
2298 settings.save_project(tmp.path()).unwrap();
2299
2300 let content = fs::read_to_string(&toml_path).unwrap();
2302 assert!(content.contains("new-theme"));
2303 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2304 }
2305
2306 #[test]
2307 fn test_save_project_creates_json_by_default() {
2308 let tmp = tempfile::tempdir().unwrap();
2309 let oxi_dir = tmp.path().join(".oxi");
2310 fs::create_dir_all(&oxi_dir).unwrap();
2311 let mut settings = Settings::default();
2314 settings.theme = "json-theme".to_string();
2315 settings.save_project(tmp.path()).unwrap();
2316
2317 let json_path = oxi_dir.join("settings.json");
2319 assert!(json_path.exists());
2320 let content = fs::read_to_string(&json_path).unwrap();
2321 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2322 assert!(content.contains("json-theme"));
2323 }
2324
2325 #[test]
2328 fn test_custom_provider_default_api() {
2329 use super::CustomProvider;
2330 let cp = CustomProvider {
2331 name: "test".to_string(),
2332 base_url: "https://api.test.com/v1".to_string(),
2333 api_key_env: "TEST_API_KEY".to_string(),
2334 api: super::default_custom_provider_api(),
2335 };
2336 assert_eq!(cp.api, "openai-completions");
2337 }
2338
2339 #[test]
2340 fn test_custom_provider_toml_deserialize() {
2341 let toml_content = r#"
2342[[custom_providers]]
2343name = "minimax"
2344base_url = "https://api.minimax.chat/v1"
2345api_key_env = "MINIMAX_API_KEY"
2346api = "openai-completions"
2347
2348[[custom_providers]]
2349name = "zai"
2350base_url = "https://api.z.ai/v1"
2351api_key_env = "ZAI_API_KEY"
2352api = "openai-responses"
2353"#;
2354 let settings: Settings = toml::from_str(toml_content).unwrap();
2355 assert_eq!(settings.custom_providers.len(), 2);
2356 assert_eq!(settings.custom_providers[0].name, "minimax");
2357 assert_eq!(
2358 settings.custom_providers[0].base_url,
2359 "https://api.minimax.chat/v1"
2360 );
2361 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2362 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2363 assert_eq!(settings.custom_providers[1].name, "zai");
2364 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2365 }
2366
2367 #[test]
2368 fn test_custom_provider_json_deserialize() {
2369 let json_content = r#"{
2370 "custom_providers": [
2371 {
2372 "name": "minimax",
2373 "base_url": "https://api.minimax.chat/v1",
2374 "api_key_env": "MINIMAX_API_KEY",
2375 "api": "openai-completions"
2376 }
2377 ]
2378 }"#;
2379 let settings: Settings = serde_json::from_str(json_content).unwrap();
2380 assert_eq!(settings.custom_providers.len(), 1);
2381 assert_eq!(settings.custom_providers[0].name, "minimax");
2382 }
2383
2384 #[test]
2385 fn test_custom_provider_toml_roundtrip() {
2386 let mut settings = Settings::default();
2387 settings.custom_providers.push(super::CustomProvider {
2388 name: "test".to_string(),
2389 base_url: "https://api.test.com/v1".to_string(),
2390 api_key_env: "TEST_API_KEY".to_string(),
2391 api: "openai-completions".to_string(),
2392 });
2393
2394 let toml_str = toml::to_string_pretty(&settings).unwrap();
2395 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2396 assert_eq!(parsed.custom_providers.len(), 1);
2397 assert_eq!(parsed.custom_providers[0].name, "test");
2398 assert_eq!(
2399 parsed.custom_providers[0].base_url,
2400 "https://api.test.com/v1"
2401 );
2402 }
2403
2404 #[test]
2405 fn test_custom_provider_defaults_empty() {
2406 let settings = Settings::default();
2407 assert!(settings.custom_providers.is_empty());
2408 }
2409
2410 #[test]
2411 fn test_custom_provider_layer_file() {
2412 let base = Settings::default();
2413
2414 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2415 let toml_content = r#"
2416[[custom_providers]]
2417name = "my-provider"
2418base_url = "https://api.my-provider.com/v1"
2419api_key_env = "MY_PROVIDER_API_KEY"
2420"#;
2421 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2422
2423 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2424 assert_eq!(merged.custom_providers.len(), 1);
2425 assert_eq!(merged.custom_providers[0].name, "my-provider");
2426 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2428 }
2429}