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 = 10;
44
45#[allow(dead_code)]
48const ENV_PREFIX: &str = "OXI_";
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
52#[serde(rename_all = "snake_case")]
53pub enum ThinkingLevel {
54 #[default]
56 Off,
57 Minimal,
59 Low,
61 Medium,
63 High,
65 XHigh,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
75#[serde(rename_all = "snake_case")]
76pub enum EditFormat {
77 #[default]
79 Hashline,
80 StrReplace,
82}
83#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct CustomProvider {
89 pub name: String,
91 pub base_url: String,
93 pub api_key_env: String,
95 #[serde(default = "default_custom_provider_api")]
97 pub api: String,
98}
99
100fn default_custom_provider_api() -> String {
101 "openai-completions".to_string()
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct Settings {
107 #[serde(default)]
110 pub version: u32,
111
112 #[serde(default = "default_thinking_level")]
115 pub thinking_level: ThinkingLevel,
116 #[serde(default = "default_theme")]
118 pub theme: String,
119
120 #[serde(default)]
127 pub glyph_set: GlyphSet,
128
129 #[serde(default, skip_serializing)]
131 pub default_model: Option<String>,
132
133 #[serde(default, skip_serializing)]
135 pub default_provider: Option<String>,
136
137 #[serde(default)]
140 pub last_used_model: Option<String>,
141
142 #[serde(default)]
144 pub last_used_provider: Option<String>,
145
146 pub max_tokens: Option<u32>,
148
149 pub temperature: Option<f32>,
151
152 pub default_temperature: Option<f64>,
154
155 pub max_response_tokens: Option<usize>,
157
158 #[serde(default = "default_session_history_size")]
161 pub session_history_size: usize,
162
163 pub session_dir: Option<PathBuf>,
165
166 #[serde(default = "default_true")]
169 pub extensions_enabled: bool,
170
171 #[serde(default = "default_true")]
173 pub auto_compaction: bool,
174
175 #[serde(default)]
178 pub disabled_tools: Vec<String>,
179
180 #[serde(default = "default_tool_timeout")]
183 pub tool_timeout_seconds: u64,
184
185 #[serde(default, alias = "questionnaire_timeout_secs")]
188 pub ask_timeout_secs: u64,
189
190 #[serde(default)]
193 pub extensions: Vec<String>,
194
195 #[serde(default)]
197 pub skills: Vec<String>,
198
199 #[serde(default)]
201 pub prompts: Vec<String>,
202
203 #[serde(default)]
205 pub themes: Vec<String>,
206
207 #[serde(default)]
210 pub custom_providers: Vec<CustomProvider>,
211
212 #[serde(default)]
217 pub dynamic_models: HashMap<String, Vec<String>>,
218
219 #[serde(default)]
224 pub keybindings: HashMap<String, Vec<String>>,
225
226 #[serde(default)]
235 pub edit_format: EditFormat,
236
237 #[serde(default = "default_true")]
241 pub todo_panel_enabled: bool,
242
243 #[serde(default = "default_true")]
246 pub agent_hub_enabled: bool,
247
248 #[serde(default = "default_false")]
251 pub snapcompact_enabled: bool,
252
253 #[serde(default = "default_true")]
256 pub mermaid_render_enabled: bool,
257
258 #[serde(default = "default_false")]
261 pub commit_tool_enabled: bool,
262
263 #[serde(default = "default_false")]
267 pub memory_enabled: bool,
268
269 #[serde(default)]
272 pub memory_db_path: Option<PathBuf>,
273
274 #[serde(default = "default_false")]
278 pub mnemopi_engine: bool,
279
280 #[serde(default)]
285 pub memory_backend: Option<String>,
286
287 #[serde(default = "default_false")]
293 pub memory_llm_extract: bool,
294
295 #[serde(default)]
299 pub memory_llm_extract_model: String,
300
301 #[serde(default = "default_embedding_provider")]
305 pub embedding_provider: String,
306
307 #[serde(default)]
309 pub embedding_base_url: Option<String>,
310
311 #[serde(default = "default_embedding_api_key_env")]
314 pub embedding_api_key_env: String,
315
316 #[serde(default = "default_embedding_model")]
319 pub embedding_model: String,
320
321 #[serde(default = "default_false")]
325 pub ttsr_enabled: bool,
326
327 #[serde(default = "default_ttsr_mode")]
329 pub ttsr_interrupt_mode: String,
330
331 #[serde(default)]
342 pub model_roles: HashMap<String, String>,
343 #[serde(default)]
347 pub advisor: AdvisorSettings,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct AdvisorSettings {
358 #[serde(default = "default_false")]
360 pub enabled: bool,
361 #[serde(default = "default_advisor_sync_backlog")]
365 pub sync_backlog: String,
366 #[serde(default)]
370 pub immune_turns: u64,
371}
372
373impl Default for AdvisorSettings {
374 fn default() -> Self {
375 Self {
376 enabled: false,
377 sync_backlog: default_advisor_sync_backlog(),
378 immune_turns: 0,
379 }
380 }
381}
382
383fn default_advisor_sync_backlog() -> String {
384 "off".to_string()
385}
386
387fn default_theme() -> String {
388 "default".to_string()
389}
390
391fn default_thinking_level() -> ThinkingLevel {
392 ThinkingLevel::Medium
393}
394
395fn default_session_history_size() -> usize {
396 100
397}
398
399fn default_true() -> bool {
400 true
401}
402
403fn default_false() -> bool {
404 false
405}
406
407fn default_ttsr_mode() -> String {
408 "prose_only".to_string()
409}
410
411fn default_tool_timeout() -> u64 {
412 120
413}
414
415fn default_memory_backend() -> Option<String> {
416 None
417}
418
419fn default_embedding_provider() -> String {
420 "none".to_string()
421}
422
423fn default_embedding_model() -> String {
424 String::new()
425}
426
427fn default_embedding_api_key_env() -> String {
428 "OPENAI_API_KEY".to_string()
429}
430
431impl Default for Settings {
432 fn default() -> Self {
433 Self {
434 version: SETTINGS_VERSION,
435 thinking_level: ThinkingLevel::Medium,
436 theme: default_theme(),
437 glyph_set: GlyphSet::default(),
438 last_used_model: None,
439 last_used_provider: None,
440 default_model: None,
441 default_provider: None,
442 max_tokens: None,
443 temperature: None,
444 default_temperature: None,
445 max_response_tokens: None,
446 session_history_size: default_session_history_size(),
447 session_dir: None,
448 extensions_enabled: true,
449 auto_compaction: true,
450 disabled_tools: Vec::new(),
451 tool_timeout_seconds: default_tool_timeout(),
452 ask_timeout_secs: 0,
453 extensions: Vec::new(),
454 skills: Vec::new(),
455 prompts: Vec::new(),
456 themes: Vec::new(),
457 custom_providers: Vec::new(),
458 dynamic_models: HashMap::new(),
459 keybindings: HashMap::new(),
460 edit_format: EditFormat::default(),
461 memory_enabled: false,
462 memory_db_path: None,
463 mnemopi_engine: false,
464 memory_backend: default_memory_backend(),
465 memory_llm_extract: false,
466 memory_llm_extract_model: String::new(),
467 embedding_provider: default_embedding_provider(),
468 embedding_base_url: None,
469 embedding_api_key_env: default_embedding_api_key_env(),
470 embedding_model: default_embedding_model(),
471 todo_panel_enabled: true,
472 agent_hub_enabled: true,
473 snapcompact_enabled: false,
474 mermaid_render_enabled: true,
475 commit_tool_enabled: false,
476 ttsr_enabled: false,
477 ttsr_interrupt_mode: default_ttsr_mode(),
478 model_roles: HashMap::new(),
479 advisor: AdvisorSettings::default(),
480 }
481 }
482}
483
484impl Settings {
485 pub fn settings_dir() -> Result<PathBuf> {
489 let base = dirs::home_dir().context("Cannot determine home directory")?;
490 Ok(base.join(".oxi"))
491 }
492
493 pub fn settings_toml_path() -> Result<PathBuf> {
495 Ok(Self::settings_dir()?.join("settings.toml"))
496 }
497
498 pub fn settings_json_path() -> Result<PathBuf> {
500 Ok(Self::settings_dir()?.join("settings.json"))
501 }
502
503 pub fn settings_path() -> Result<PathBuf> {
510 let json_path = Self::settings_json_path()?;
511 let toml_path = Self::settings_toml_path()?;
512
513 if json_path.exists() && toml_path.exists() {
514 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
516 return Ok(json_path);
517 }
518
519 if json_path.exists() {
520 return Ok(json_path);
521 }
522
523 if toml_path.exists() {
524 return Ok(toml_path);
525 }
526
527 Ok(json_path)
529 }
530
531 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
536 let json_path = Self::settings_json_path()?;
537 let toml_path = Self::settings_toml_path()?;
538
539 let (primary, secondary) = if prefer_json {
540 (&json_path, &toml_path)
541 } else {
542 (&toml_path, &json_path)
543 };
544
545 if primary.exists() {
546 return Ok(primary.clone());
547 }
548
549 if secondary.exists() {
550 return Ok(secondary.clone());
551 }
552
553 Ok(primary.clone())
555 }
556
557 pub fn detect_format(path: &Path) -> SettingsFormat {
559 match path.extension().and_then(|e| e.to_str()) {
560 Some("json") => SettingsFormat::Json,
561 Some("toml") => SettingsFormat::Toml,
562 _ => SettingsFormat::Json, }
564 }
565
566 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
571 let mut dir = start_dir.to_path_buf();
572 loop {
573 let json_candidate = dir.join(".oxi").join("settings.json");
575 if json_candidate.exists() {
576 return Some(json_candidate);
577 }
578
579 let toml_candidate = dir.join(".oxi").join("settings.toml");
580 if toml_candidate.exists() {
581 return Some(toml_candidate);
582 }
583
584 if !dir.pop() {
585 return None;
586 }
587 }
588 }
589
590 pub fn effective_session_dir(&self) -> Result<PathBuf> {
594 if let Some(ref dir) = self.session_dir {
595 return Ok(dir.clone());
596 }
597 Ok(Self::settings_dir()?.join("sessions"))
598 }
599
600 pub fn load() -> Result<Self> {
618 Self::load_from_cwd()
619 }
620
621 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
627 Self::load_from_with(dir, None)
628 }
629
630 pub fn load_from_with(
649 dir: &std::path::Path,
650 global_override: Option<&std::path::Path>,
651 ) -> Result<Self> {
652 let mut settings = Settings::default();
654
655 let resolved_global: Option<std::path::PathBuf> = match global_override {
658 Some(p) => Some(p.to_path_buf()),
659 None => Self::settings_path().ok(),
660 };
661 if let Some(ref gp) = resolved_global
662 && gp.exists()
663 {
664 settings = Self::layer_file(&settings, gp)?;
665 }
666
667 if let Some(project_path) = Self::find_project_settings(dir) {
669 settings = Self::layer_file(&settings, &project_path)?;
670 }
671
672 settings.apply_env();
674
675 settings = Self::migrate(settings)?;
677
678 Ok(settings)
681 }
682
683 pub fn load_from_cwd() -> Result<Self> {
685 let cwd = env::current_dir().context("Cannot determine current directory")?;
686 Self::load_from(&cwd)
687 }
688
689 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
695 let content = fs::read_to_string(path)
696 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
697
698 let format = Self::detect_format(path);
699 let overlay: serde_json::Value = match format {
700 SettingsFormat::Toml => {
701 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
702 format!("Failed to parse TOML settings from {}", path.display())
703 })?;
704 toml_value_to_json(toml_value)
706 }
707 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
708 format!("Failed to parse JSON settings from {}", path.display())
709 })?,
710 };
711
712 let base_json =
716 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
717
718 let merged = merge_json_values(base_json, overlay);
719 let result: Settings =
720 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
721
722 Ok(result)
723 }
724
725 #[allow(dead_code)]
750 pub fn apply_env(&mut self) {
751 }
755
756 #[allow(dead_code)]
762 pub fn from_env() -> Self {
763 Self::default()
764 }
765
766 pub fn save(&self) -> Result<()> {
773 let dir = Self::settings_dir()?;
774 let path = Self::settings_path()?;
775
776 if !dir.exists() {
777 fs::create_dir_all(&dir).with_context(|| {
778 format!("Failed to create settings directory {}", dir.display())
779 })?;
780 }
781
782 let format = Self::detect_format(&path);
783 let content = Self::serialize_for_format(self, format)?;
784
785 let tmp_path = path.with_extension("tmp");
787 fs::write(&tmp_path, &content)
788 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
789 fs::rename(&tmp_path, &path)
790 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
791
792 Ok(())
793 }
794
795 pub fn save_to(&self, path: &Path) -> Result<()> {
797 if let Some(parent) = path.parent()
798 && !parent.exists()
799 {
800 fs::create_dir_all(parent)
801 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
802 }
803
804 let format = Self::detect_format(path);
805 let content = Self::serialize_for_format(self, format)?;
806
807 let tmp_path = path.with_extension("tmp");
809 fs::write(&tmp_path, &content)
810 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
811 fs::rename(&tmp_path, path)
812 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
813
814 Ok(())
815 }
816
817 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
821 let dir = project_dir.join(".oxi");
822
823 if !dir.exists() {
824 fs::create_dir_all(&dir).with_context(|| {
825 format!(
826 "Failed to create project settings directory {}",
827 dir.display()
828 )
829 })?;
830 }
831
832 let json_path = dir.join("settings.json");
834 let toml_path = dir.join("settings.toml");
835
836 let path = if json_path.exists() {
837 &json_path
838 } else if toml_path.exists() {
839 &toml_path
840 } else {
841 &json_path
843 };
844
845 let format = Self::detect_format(path);
846 let content = Self::serialize_for_format(self, format)?;
847
848 let tmp_path = path.with_extension("tmp");
850 fs::write(&tmp_path, &content)
851 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
852 fs::rename(&tmp_path, path)
853 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
854
855 Ok(())
856 }
857
858 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
860 match format {
861 SettingsFormat::Toml => {
862 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
863 }
864 SettingsFormat::Json => serde_json::to_string_pretty(settings)
865 .context("Failed to serialize settings to JSON"),
866 }
867 }
868
869 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
871 match format {
872 SettingsFormat::Toml => {
873 toml::from_str(content).context("Failed to parse TOML settings")
874 }
875 SettingsFormat::Json => {
876 serde_json::from_str(content).context("Failed to parse JSON settings")
877 }
878 }
879 }
880
881 pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
890 if let Some(m) = model {
891 self.last_used_model = Some(m);
892 }
893 if let Some(p) = provider {
894 self.last_used_provider = Some(p);
895 }
896 }
897
898 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
901 cli_model.map(String::from).or_else(|| {
902 let model = self.last_used_model.as_ref()?;
907 if model.contains('/') {
908 Some(model.clone())
910 } else if let Some(ref provider) = self.last_used_provider {
911 Some(format!("{}/{}", provider, model))
913 } else {
914 Some(model.clone())
915 }
916 })
917 }
918
919 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
922 cli_provider
923 .map(String::from)
924 .or_else(|| self.last_used_provider.clone())
925 }
926
927 pub fn effective_temperature(&self) -> Option<f64> {
930 self.default_temperature
931 .or(self.temperature.map(|t| t as f64))
932 }
933
934 pub fn effective_max_tokens(&self) -> Option<usize> {
937 self.max_response_tokens
938 .or(self.max_tokens.map(|t| t as usize))
939 }
940
941 pub fn save_last_used(model_id: &str) {
947 if let Ok(mut settings) = Self::load() {
948 if let Some((provider, model)) = model_id.split_once('/') {
949 settings.last_used_provider = Some(provider.to_string());
950 settings.last_used_model = Some(model.to_string());
951 } else {
952 settings.last_used_model = Some(model_id.to_string());
953 }
954 let _ = settings.save();
955 }
956 }
957
958 pub fn save_theme(&mut self, name: &str) -> Result<()> {
960 self.theme = name.to_string();
961 self.save()
962 }
963
964 pub fn get_theme_name(&self) -> String {
966 if self.theme.is_empty() || self.theme == "default" {
967 "oxi_dark".to_string()
968 } else {
969 self.theme.clone()
970 }
971 }
972
973 fn migrate(settings: Settings) -> Result<Settings> {
987 let mut settings = settings;
988
989 match settings.version {
990 SETTINGS_VERSION => {
991 }
993 0 => {
994 if settings.tool_timeout_seconds == 0 {
997 settings.tool_timeout_seconds = default_tool_timeout();
998 }
999 settings.version = SETTINGS_VERSION;
1000
1001 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1002 }
1003 1 | 2 => {
1004 settings.version = SETTINGS_VERSION;
1008 tracing::info!(
1009 "Migrated settings from version {} to {}",
1010 settings.version,
1011 SETTINGS_VERSION
1012 );
1013 }
1014 3 => {
1015 if let Some(model) = settings.default_model.take() {
1018 if let Some((provider, model_name)) = model.split_once('/') {
1019 settings.last_used_provider = Some(provider.to_string());
1020 settings.last_used_model = Some(model_name.to_string());
1021 } else {
1022 settings.last_used_model = Some(model);
1023 }
1024 }
1025 settings.version = SETTINGS_VERSION;
1026 tracing::info!(
1027 "Migrated settings from version 3 to {} (default_model → last_used_model)",
1028 SETTINGS_VERSION
1029 );
1030 }
1031 4 => {
1032 settings.version = SETTINGS_VERSION;
1034 tracing::info!("Migrated settings from version 4 to {}", SETTINGS_VERSION);
1035 }
1036 5 => {
1037 settings.version = SETTINGS_VERSION;
1039 tracing::info!("Migrated settings from version 5 to {}", SETTINGS_VERSION);
1040 }
1041 6 => {
1042 settings.version = SETTINGS_VERSION;
1045 tracing::info!(
1046 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1047 SETTINGS_VERSION
1048 );
1049 }
1050 7 => {
1051 settings.version = SETTINGS_VERSION;
1054 tracing::info!(
1055 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1056 SETTINGS_VERSION
1057 );
1058 }
1059 8 => {
1060 settings.version = SETTINGS_VERSION;
1063 tracing::info!(
1064 "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1065 SETTINGS_VERSION
1066 );
1067 }
1068 v if v > SETTINGS_VERSION => {
1069 anyhow::bail!(
1071 "Settings version {} is newer than supported version {}. \
1072 Please update oxi.",
1073 v,
1074 SETTINGS_VERSION
1075 );
1076 }
1077 v => {
1078 tracing::warn!(
1080 "Unknown settings version {}, attempting migration to {}",
1081 v,
1082 SETTINGS_VERSION
1083 );
1084 settings.version = SETTINGS_VERSION;
1085 }
1086 }
1087
1088 Ok(settings)
1089 }
1090}
1091
1092#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1096pub enum SettingsFormat {
1097 #[default]
1099 Json,
1100 Toml,
1102}
1103
1104impl SettingsFormat {
1105 pub fn extension(&self) -> &'static str {
1107 match self {
1108 SettingsFormat::Json => "json",
1109 SettingsFormat::Toml => "toml",
1110 }
1111 }
1112}
1113
1114fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1118 match toml {
1119 toml::Value::String(s) => serde_json::Value::String(s),
1120 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1121 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1122 .map(serde_json::Value::Number)
1123 .unwrap_or(serde_json::Value::Null),
1124 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1125 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1126 toml::Value::Array(arr) => {
1127 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1128 }
1129 toml::Value::Table(table) => {
1130 let obj = table
1131 .into_iter()
1132 .map(|(k, v)| (k, toml_value_to_json(v)))
1133 .collect();
1134 serde_json::Value::Object(obj)
1135 }
1136 }
1137}
1138
1139fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1141 match (base, override_) {
1142 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1144 let mut result = base_map;
1145 for (key, override_value) in override_map {
1146 let base_value = result.remove(&key);
1147 let merged = match base_value {
1148 Some(base_v) => merge_json_values(base_v, override_value),
1149 None => override_value,
1150 };
1151 result.insert(key, merged);
1152 }
1153 serde_json::Value::Object(result)
1154 }
1155 (_, override_) => override_,
1157 }
1158}
1159
1160pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1162 match s.to_lowercase().as_str() {
1163 "off" | "none" => Some(ThinkingLevel::Off),
1164 "minimal" => Some(ThinkingLevel::Minimal),
1165 "low" => Some(ThinkingLevel::Low),
1166 "medium" | "standard" => Some(ThinkingLevel::Medium),
1167 "high" | "thorough" => Some(ThinkingLevel::High),
1168 "xhigh" => Some(ThinkingLevel::XHigh),
1169 _ => None,
1170 }
1171}
1172
1173#[allow(dead_code)]
1175fn parse_boolish(s: &str) -> Result<bool> {
1176 match s.to_lowercase().as_str() {
1177 "true" | "1" | "yes" | "on" => Ok(true),
1178 "false" | "0" | "no" | "off" => Ok(false),
1179 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1180 }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185 use super::*;
1186 use std::io::Write as IoWrite;
1187 use std::sync::Mutex;
1188
1189 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1192
1193 struct EnvGuard {
1196 saved: Vec<(String, Option<String>)>,
1197 }
1198
1199 impl EnvGuard {
1200 fn new(vars: &[&str]) -> Self {
1201 let saved = vars
1202 .iter()
1203 .map(|&name| {
1204 let old = env::var(name).ok();
1205 unsafe { env::remove_var(name) };
1207 (name.to_string(), old)
1208 })
1209 .collect();
1210 Self { saved }
1211 }
1212 }
1213
1214 impl Drop for EnvGuard {
1215 fn drop(&mut self) {
1216 for (name, old) in self.saved.drain(..) {
1217 match old {
1218 Some(val) => unsafe { env::set_var(&name, val) },
1220 None => unsafe { env::remove_var(&name) },
1221 }
1222 }
1223 }
1224 }
1225
1226 #[test]
1229 fn test_default_settings() {
1230 let settings = Settings::default();
1231 assert_eq!(settings.version, SETTINGS_VERSION);
1232 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1233 assert_eq!(settings.theme, "default");
1234 assert!(settings.last_used_model.is_none());
1235 assert!(settings.last_used_provider.is_none());
1236 assert!(settings.extensions_enabled);
1237 assert!(settings.auto_compaction);
1238 assert_eq!(settings.tool_timeout_seconds, 120);
1239 }
1240
1241 #[test]
1242 fn test_merge_cli() {
1243 let mut settings = Settings::default();
1244 settings.last_used_model = Some("gpt-4o".to_string());
1245
1246 settings.merge_cli(Some("claude".to_string()), None);
1247 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1248
1249 settings.merge_cli(None, Some("google".to_string()));
1250 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1251 }
1252
1253 #[test]
1256 fn test_layer_file_overrides() {
1257 let base = Settings::default();
1258
1259 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1260 let toml_content = r#"
1261last_used_model = "openai/gpt-4o"
1262theme = "dracula"
1263"#;
1264 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1265
1266 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1267 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1268 assert_eq!(merged.theme, "dracula");
1269 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1271 assert!(merged.extensions_enabled);
1272 }
1273
1274 #[test]
1275 fn test_layer_file_preserves_unset() {
1276 let mut base = Settings::default();
1277 base.last_used_provider = Some("deepseek".to_string());
1278
1279 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1280 let toml_content = "theme = \"monokai\"\n";
1282 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1283
1284 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1285 assert_eq!(merged.theme, "monokai");
1286 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1287 }
1288
1289 #[test]
1290 fn test_load_from_dir_with_project_config() {
1291 let _guard = EnvGuard::new(&[
1292 "OXI_MODEL",
1293 "OXI_PROVIDER",
1294 "OXI_THEME",
1295 "OXI_TOOL_TIMEOUT",
1296 "OXI_TEMPERATURE",
1297 "OXI_MAX_TOKENS",
1298 "OXI_SESSION_DIR",
1299 "OXI_EXTENSIONS_ENABLED",
1300 ]);
1301 let tmp = tempfile::tempdir().unwrap();
1302 let oxi_dir = tmp.path().join(".oxi");
1303 fs::create_dir_all(&oxi_dir).unwrap();
1304 let settings_path = oxi_dir.join("settings.toml");
1305 fs::write(
1307 &settings_path,
1308 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1309 )
1310 .unwrap();
1311
1312 let settings = Settings::load_from(tmp.path()).unwrap();
1313 assert_eq!(
1315 settings.last_used_model,
1316 Some("gemini-2.0-flash".to_string())
1317 );
1318 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1319 }
1320
1321 #[test]
1322 fn test_load_from_dir_no_config() {
1323 let _guard = EnvGuard::new(&[
1325 "OXI_MODEL",
1326 "OXI_PROVIDER",
1327 "OXI_THEME",
1328 "OXI_TOOL_TIMEOUT",
1329 "OXI_TEMPERATURE",
1330 "OXI_MAX_TOKENS",
1331 "OXI_SESSION_DIR",
1332 "OXI_EXTENSIONS_ENABLED",
1333 ]);
1334 let tmp = tempfile::tempdir().unwrap();
1335 let global = tmp.path().join("nonexistent-settings.json");
1340 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1341 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1342 }
1343 #[test]
1344 fn test_from_env() {
1345 let _guard = EnvGuard::new(&[
1348 "OXI_MODEL",
1350 "OXI_THEME",
1351 "OXI_TOOL_TIMEOUT",
1352 "OXI_PROVIDER",
1353 "OXI_DEFAULT_MODEL",
1354 ]);
1355
1356 let settings = Settings::from_env();
1357 assert_eq!(settings.last_used_model, None);
1359 assert_eq!(settings.theme, "default");
1360 assert_eq!(settings.tool_timeout_seconds, 120);
1361 }
1362
1363 #[test]
1364 fn test_apply_env_boolish() {
1365 let _guard = EnvGuard::new(&["OXI_EXTENSIONS_ENABLED"]);
1368 unsafe { env::set_var("OXI_EXTENSIONS_ENABLED", "0") };
1369
1370 let mut settings = Settings::default();
1371 settings.apply_env();
1372 assert!(settings.extensions_enabled); }
1375
1376 #[test]
1377 fn test_apply_env_temperature() {
1378 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
1380 unsafe { env::set_var("OXI_TEMPERATURE", "0.7") };
1381
1382 let mut settings = Settings::default();
1383 settings.apply_env();
1384 assert_eq!(settings.default_temperature, None);
1386 }
1387
1388 #[test]
1389 fn test_env_does_not_override_when_unset() {
1390 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER", "OXI_THEME", "OXI_TEMPERATURE"]);
1391 let settings = Settings::from_env();
1392 assert!(settings.last_used_model.is_none());
1393 assert!(settings.last_used_provider.is_none());
1394 }
1395
1396 #[test]
1397 fn test_parse_thinking_level() {
1398 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1399 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1400 assert_eq!(
1401 parse_thinking_level("MINIMAL"),
1402 Some(ThinkingLevel::Minimal)
1403 );
1404 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1405 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1406 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1407 assert_eq!(
1408 parse_thinking_level("Standard"),
1409 Some(ThinkingLevel::Medium)
1410 );
1411 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1412 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1413 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1414 assert_eq!(parse_thinking_level("invalid"), None);
1415 }
1416
1417 #[test]
1418 fn test_parse_boolish() {
1419 assert!(parse_boolish("true").unwrap());
1420 assert!(parse_boolish("1").unwrap());
1421 assert!(parse_boolish("yes").unwrap());
1422 assert!(parse_boolish("ON").unwrap());
1423 assert!(!parse_boolish("false").unwrap());
1424 assert!(!parse_boolish("0").unwrap());
1425 assert!(!parse_boolish("no").unwrap());
1426 assert!(!parse_boolish("OFF").unwrap());
1427 assert!(parse_boolish("maybe").is_err());
1428 }
1429
1430 #[test]
1433 fn test_effective_model_returns_last_used() {
1434 let mut settings = Settings::default();
1435 settings.last_used_model = Some("openai/gpt-4o".to_string());
1436 assert_eq!(
1437 settings.effective_model(None),
1438 Some("openai/gpt-4o".to_string())
1439 );
1440 }
1441
1442 #[test]
1443 fn test_effective_model_cli_overrides() {
1444 let mut settings = Settings::default();
1445 settings.last_used_model = Some("openai/gpt-4o".to_string());
1446 assert_eq!(
1447 settings.effective_model(Some("anthropic/claude-3")),
1448 Some("anthropic/claude-3".to_string())
1449 );
1450 }
1451
1452 #[test]
1453 fn test_effective_model_none_when_unset() {
1454 let settings = Settings::default();
1455 assert_eq!(settings.effective_model(None), None);
1456 }
1457
1458 #[test]
1459 fn test_effective_model_falls_back_to_last_used() {
1460 let mut settings = Settings::default();
1461 settings.last_used_model = Some("anthropic/claude-3".to_string());
1462 assert_eq!(
1463 settings.effective_model(None),
1464 Some("anthropic/claude-3".to_string())
1465 );
1466 }
1467
1468 #[test]
1469 fn test_effective_model_returns_none_when_nothing_set() {
1470 let settings = Settings::default();
1471 assert_eq!(settings.effective_model(None), None);
1472 }
1473
1474 #[test]
1475 fn test_effective_temperature_prefers_f64() {
1476 let mut settings = Settings::default();
1477 settings.temperature = Some(0.5);
1478 settings.default_temperature = Some(0.7);
1479 assert_eq!(settings.effective_temperature(), Some(0.7));
1480 }
1481
1482 #[test]
1483 fn test_effective_temperature_falls_back_to_f32() {
1484 let mut settings = Settings::default();
1485 settings.temperature = Some(0.5);
1486 assert_eq!(settings.effective_temperature(), Some(0.5));
1487 }
1488
1489 #[test]
1490 fn test_effective_max_tokens_prefers_usize() {
1491 let mut settings = Settings::default();
1492 settings.max_tokens = Some(1024);
1493 settings.max_response_tokens = Some(4096);
1494 assert_eq!(settings.effective_max_tokens(), Some(4096));
1495 }
1496
1497 #[test]
1498 fn test_effective_max_tokens_falls_back_to_u32() {
1499 let mut settings = Settings::default();
1500 settings.max_tokens = Some(1024);
1501 assert_eq!(settings.effective_max_tokens(), Some(1024));
1502 }
1503
1504 #[test]
1507 fn test_effective_session_dir_default() {
1508 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1509 let settings = Settings::default();
1510 let dir = settings.effective_session_dir().unwrap();
1511 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1512 }
1513
1514 #[test]
1515 fn test_effective_session_dir_from_field() {
1516 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1517 let mut settings = Settings::default();
1518 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
1519 assert_eq!(
1520 settings.effective_session_dir().unwrap(),
1521 PathBuf::from("/tmp/oxi-sessions")
1522 );
1523 }
1524
1525 #[test]
1526 fn test_effective_session_dir_env_disabled() {
1527 let _guard = EnvGuard::new(&["OXI_SESSION_DIR"]);
1530 unsafe { env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions") };
1531 let settings = Settings::default();
1532 let dir = settings.effective_session_dir().unwrap();
1534 assert!(
1535 dir.ends_with("sessions"),
1536 "expected default sessions dir, got: {:?}",
1537 dir
1538 );
1539 }
1540
1541 #[test]
1544 fn test_migration_v0_to_v1() {
1545 let mut settings = Settings::default();
1546 settings.version = 0;
1547 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1550 assert_eq!(migrated.version, SETTINGS_VERSION);
1551 assert_eq!(migrated.tool_timeout_seconds, 120);
1552 }
1553
1554 #[test]
1555 fn test_migration_already_current() {
1556 let settings = Settings::default();
1557 let migrated = Settings::migrate(settings).unwrap();
1558 assert_eq!(migrated.version, SETTINGS_VERSION);
1559 }
1560
1561 #[test]
1562 fn test_migration_v3_to_v4_splits_model() {
1563 let mut settings = Settings::default();
1564 settings.version = 3;
1565 settings.default_model = Some("openai/gpt-4o".to_string());
1566 settings.default_provider = None;
1567
1568 let migrated = Settings::migrate(settings).unwrap();
1569 assert_eq!(migrated.version, SETTINGS_VERSION);
1570 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1571 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1572 }
1573
1574 #[test]
1575 fn test_migration_v3_no_slash_keeps_model() {
1576 let mut settings = Settings::default();
1577 settings.version = 3;
1578 settings.default_model = Some("bare-model-name".to_string());
1579
1580 let migrated = Settings::migrate(settings).unwrap();
1581 assert_eq!(migrated.version, SETTINGS_VERSION);
1582 assert_eq!(
1583 migrated.last_used_model,
1584 Some("bare-model-name".to_string())
1585 );
1586 }
1587
1588 #[test]
1589 fn test_migration_future_version_fails() {
1590 let mut settings = Settings::default();
1591 settings.version = 9999;
1592 assert!(Settings::migrate(settings).is_err());
1593 }
1594
1595 #[test]
1596 fn test_default_glyph_set_is_unicode() {
1597 let settings = Settings::default();
1598 assert_eq!(
1599 settings.glyph_set,
1600 GlyphSet::Unicode,
1601 "glyph_set must default to Unicode"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1607 let mut settings = Settings::default();
1610 settings.version = 7;
1611 settings.glyph_set = GlyphSet::default();
1613
1614 let migrated = Settings::migrate(settings).unwrap();
1615 assert_eq!(migrated.version, SETTINGS_VERSION);
1616 assert_eq!(
1617 migrated.glyph_set,
1618 GlyphSet::Unicode,
1619 "v7 → v8 migration must default glyph_set to unicode"
1620 );
1621 }
1622
1623 #[test]
1624 fn test_glyph_set_persists_through_roundtrip() {
1625 let mut original = Settings::default();
1629 original.glyph_set = GlyphSet::Nerd;
1630 let content = toml::to_string_pretty(&original).unwrap();
1631 assert!(
1632 content.contains("glyph_set = \"nerd\""),
1633 "nerd preset must serialize to snake_case; got:\n{content}"
1634 );
1635 let loaded: Settings = toml::from_str(&content).unwrap();
1636 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1637 original.glyph_set = GlyphSet::Unicode;
1639 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1640 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1641 }
1642
1643 #[test]
1644 fn test_save_and_load_roundtrip() {
1645 let tmp = tempfile::tempdir().unwrap();
1646 let settings_path = tmp.path().join("settings.toml");
1647
1648 let mut original = Settings::default();
1649 original.last_used_model = Some("gpt-4o".to_string());
1650 original.last_used_provider = Some("openai".to_string());
1651 original.theme = "dracula".to_string();
1652 original.tool_timeout_seconds = 60;
1653
1654 let content = toml::to_string_pretty(&original).unwrap();
1656 fs::write(&settings_path, &content).unwrap();
1657
1658 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1660 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1661
1662 assert_eq!(loaded.last_used_model, original.last_used_model);
1663 assert_eq!(loaded.theme, original.theme);
1664 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1665 }
1666
1667 #[test]
1668 fn test_toml_roundtrip_preserves_new_fields() {
1669 let mut settings = Settings::default();
1670 settings.default_temperature = Some(0.8);
1671 settings.max_response_tokens = Some(8192);
1672 settings.auto_compaction = false;
1673 settings.extensions_enabled = false;
1674 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1675
1676 let toml_str = toml::to_string_pretty(&settings).unwrap();
1677 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1678
1679 assert_eq!(parsed.default_temperature, Some(0.8));
1680 assert_eq!(parsed.max_response_tokens, Some(8192));
1681 assert!(!parsed.auto_compaction);
1682 assert!(!parsed.extensions_enabled);
1683 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1684 }
1685
1686 #[test]
1689 fn test_json_roundtrip() {
1690 let mut settings = Settings::default();
1691 settings.last_used_model = Some("gpt-4o".to_string());
1692 settings.last_used_provider = Some("openai".to_string());
1693 settings.theme = "dracula".to_string();
1694 settings.tool_timeout_seconds = 60;
1695 settings.default_temperature = Some(0.8);
1696 settings.max_response_tokens = Some(8192);
1697
1698 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1699 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1700
1701 assert_eq!(parsed.last_used_model, settings.last_used_model);
1702 assert_eq!(parsed.theme, settings.theme);
1703 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1704 assert_eq!(parsed.default_temperature, settings.default_temperature);
1705 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1706 }
1707
1708 #[test]
1709 fn test_json_serialize_for_format() {
1710 let mut settings = Settings::default();
1711 settings.last_used_model = Some("claude-3".to_string());
1712 settings.last_used_provider = Some("anthropic".to_string());
1713 settings.thinking_level = ThinkingLevel::Minimal;
1714
1715 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1716 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1717
1718 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1719 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1720 }
1721
1722 #[test]
1723 fn test_toml_serialize_for_format() {
1724 let mut settings = Settings::default();
1725 settings.last_used_model = Some("gemini-pro".to_string());
1726 settings.last_used_provider = Some("google".to_string());
1727 settings.thinking_level = ThinkingLevel::High;
1728
1729 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1730 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1731
1732 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1733 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1734 }
1735
1736 #[test]
1737 fn test_parse_from_str_json() {
1738 let json_content = r#"{
1739 "last_used_model": "gpt-4",
1740 "last_used_provider": "openai",
1741 "theme": "nord",
1742 "tool_timeout_seconds": 90
1743 }"#;
1744
1745 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1746 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1747 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1748 assert_eq!(settings.theme, "nord");
1749 assert_eq!(settings.tool_timeout_seconds, 90);
1750 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1752 assert!(settings.extensions_enabled);
1753 }
1754
1755 #[test]
1756 fn test_parse_from_str_toml() {
1757 let toml_content = r#"
1758last_used_model = "claude-opus"
1759last_used_provider = "anthropic"
1760theme = "monokai"
1761tool_timeout_seconds = 45
1762"#;
1763
1764 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1765 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1766 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1767 assert_eq!(settings.theme, "monokai");
1768 assert_eq!(settings.tool_timeout_seconds, 45);
1769 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1770 }
1771
1772 #[test]
1773 fn test_layer_file_json() {
1774 let base = Settings::default();
1775
1776 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1777 let json_content = r#"{
1778 "last_used_model": "gpt-4o",
1779 "last_used_provider": "openai",
1780 "theme": "dracula",
1781 "auto_compaction": false
1782 }"#;
1783 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1784
1785 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1786 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1787 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1788 assert_eq!(merged.theme, "dracula");
1789 assert!(!merged.auto_compaction);
1790 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1792 assert!(merged.extensions_enabled);
1793 assert_eq!(merged.tool_timeout_seconds, 120);
1794 }
1795
1796 #[test]
1797 fn test_layer_file_json_preserves_unset() {
1798 let mut base = Settings::default();
1799 base.last_used_provider = Some("deepseek".to_string());
1800
1801 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1802 let json_content = r#"{ "theme": "nord" }"#;
1803 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1804
1805 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1806 assert_eq!(merged.theme, "nord");
1807 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1808 }
1809
1810 #[test]
1811 fn test_save_to_json() {
1812 let tmp = tempfile::tempdir().unwrap();
1813 let settings_path = tmp.path().join("settings.json");
1814
1815 let mut settings = Settings::default();
1816 settings.last_used_model = Some("gpt-4o".to_string());
1817 settings.last_used_provider = Some("openai".to_string());
1818 settings.theme = "dracula".to_string();
1819 settings.tool_timeout_seconds = 60;
1820
1821 settings.save_to(&settings_path).unwrap();
1822
1823 let content = fs::read_to_string(&settings_path).unwrap();
1825 let parsed: Settings = serde_json::from_str(&content).unwrap();
1826 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1827 assert_eq!(parsed.theme, "dracula");
1828 assert_eq!(parsed.tool_timeout_seconds, 60);
1829 }
1830
1831 #[test]
1832 fn test_save_to_toml() {
1833 let tmp = tempfile::tempdir().unwrap();
1834 let settings_path = tmp.path().join("settings.toml");
1835
1836 let mut settings = Settings::default();
1837 settings.last_used_model = Some("gemini-pro".to_string());
1838 settings.last_used_provider = Some("google".to_string());
1839 settings.theme = "monokai".to_string();
1840 settings.tool_timeout_seconds = 90;
1841
1842 settings.save_to(&settings_path).unwrap();
1843
1844 let content = fs::read_to_string(&settings_path).unwrap();
1846 let parsed: Settings = toml::from_str(&content).unwrap();
1847 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1848 assert_eq!(parsed.theme, "monokai");
1849 assert_eq!(parsed.tool_timeout_seconds, 90);
1850 }
1851
1852 #[test]
1853 fn test_load_from_dir_with_json_project_config() {
1854 let _guard = EnvGuard::new(&[
1855 "OXI_MODEL",
1856 "OXI_PROVIDER",
1857 "OXI_THEME",
1858 "OXI_TOOL_TIMEOUT",
1859 "OXI_TEMPERATURE",
1860 "OXI_MAX_TOKENS",
1861 "OXI_SESSION_DIR",
1862 "OXI_EXTENSIONS_ENABLED",
1863 ]);
1864 let tmp = tempfile::tempdir().unwrap();
1865 let oxi_dir = tmp.path().join(".oxi");
1866 fs::create_dir_all(&oxi_dir).unwrap();
1867 let settings_path = oxi_dir.join("settings.json");
1868 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1870 fs::write(&settings_path, json_content).unwrap();
1871
1872 let settings = Settings::load_from(tmp.path()).unwrap();
1873 assert_eq!(
1875 settings.last_used_model,
1876 Some("gemini-2.0-flash".to_string())
1877 );
1878 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1879 }
1880
1881 #[test]
1882 fn test_find_project_settings_json_priority() {
1883 let tmp = tempfile::tempdir().unwrap();
1884 let oxi_dir = tmp.path().join(".oxi");
1885 fs::create_dir_all(&oxi_dir).unwrap();
1886
1887 let json_path = oxi_dir.join("settings.json");
1889 let toml_path = oxi_dir.join("settings.toml");
1890 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1891 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1892
1893 let found = Settings::find_project_settings(tmp.path());
1895 assert!(found.is_some());
1896 assert_eq!(
1897 found.unwrap().file_name().unwrap().to_str().unwrap(),
1898 "settings.json"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_find_project_settings_json_only() {
1904 let tmp = tempfile::tempdir().unwrap();
1905 let oxi_dir = tmp.path().join(".oxi");
1906 fs::create_dir_all(&oxi_dir).unwrap();
1907
1908 let json_path = oxi_dir.join("settings.json");
1909 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1910
1911 let found = Settings::find_project_settings(tmp.path());
1912 assert!(found.is_some());
1913 assert_eq!(
1914 found.unwrap().file_name().unwrap().to_str().unwrap(),
1915 "settings.json"
1916 );
1917 }
1918
1919 #[test]
1920 fn test_find_project_settings_toml_fallback() {
1921 let tmp = tempfile::tempdir().unwrap();
1922 let oxi_dir = tmp.path().join(".oxi");
1923 fs::create_dir_all(&oxi_dir).unwrap();
1924
1925 let toml_path = oxi_dir.join("settings.toml");
1926 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1927
1928 let found = Settings::find_project_settings(tmp.path());
1929 assert!(found.is_some());
1930 assert_eq!(
1931 found.unwrap().file_name().unwrap().to_str().unwrap(),
1932 "settings.toml"
1933 );
1934 }
1935
1936 #[test]
1937 fn test_detect_format() {
1938 let json_path = PathBuf::from("/test/settings.json");
1939 let toml_path = PathBuf::from("/test/settings.toml");
1940 let unknown_path = PathBuf::from("/test/settings");
1941
1942 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1943 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1944 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1945 }
1947
1948 #[test]
1949 fn test_settings_format_extension() {
1950 assert_eq!(SettingsFormat::Json.extension(), "json");
1951 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1952 }
1953
1954 #[test]
1955 fn test_layer_json_over_toml() {
1956 let tmp = tempfile::tempdir().unwrap();
1958 let oxi_dir = tmp.path().join(".oxi");
1959 fs::create_dir_all(&oxi_dir).unwrap();
1960
1961 let json_path = oxi_dir.join("settings.json");
1962 let toml_path = oxi_dir.join("settings.toml");
1963
1964 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1966 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1968
1969 let settings = Settings::load_from(tmp.path()).unwrap();
1971 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1972 }
1973
1974 #[test]
1975 fn test_mixed_format_loading() {
1976 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1978 let toml_content = r#"
1979last_used_model = "loaded-via-toml"
1980theme = "loaded-theme"
1981"#;
1982 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1983
1984 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1985 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
1986 assert_eq!(merged.theme, "loaded-theme");
1987 }
1988
1989 #[test]
1990 fn test_merge_json_values() {
1991 let base = serde_json::json!({
1992 "version": 1,
1993 "theme": "default",
1994 "extensions": ["ext1"],
1995 "nested": {
1996 "a": 1,
1997 "b": 2
1998 }
1999 });
2000
2001 let override_ = serde_json::json!({
2002 "version": 2,
2003 "theme": "dark",
2004 "extensions": ["ext2"],
2005 "nested": {
2006 "b": 20,
2007 "c": 30
2008 }
2009 });
2010
2011 let merged = merge_json_values(base, override_);
2012
2013 assert_eq!(merged["version"], 2);
2014 assert_eq!(merged["theme"], "dark");
2015 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2017 assert_eq!(merged["nested"]["a"], 1);
2019 assert_eq!(merged["nested"]["b"], 20);
2020 assert_eq!(merged["nested"]["c"], 30);
2021 }
2022
2023 #[test]
2024 fn test_save_project_preserves_existing_format() {
2025 let tmp = tempfile::tempdir().unwrap();
2026 let oxi_dir = tmp.path().join(".oxi");
2027 fs::create_dir_all(&oxi_dir).unwrap();
2028
2029 let toml_path = oxi_dir.join("settings.toml");
2031 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2032
2033 let mut settings = Settings::default();
2034 settings.theme = "new-theme".to_string();
2035 settings.save_project(tmp.path()).unwrap();
2036
2037 let content = fs::read_to_string(&toml_path).unwrap();
2039 assert!(content.contains("new-theme"));
2040 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2041 }
2042
2043 #[test]
2044 fn test_save_project_creates_json_by_default() {
2045 let tmp = tempfile::tempdir().unwrap();
2046 let oxi_dir = tmp.path().join(".oxi");
2047 fs::create_dir_all(&oxi_dir).unwrap();
2048 let mut settings = Settings::default();
2051 settings.theme = "json-theme".to_string();
2052 settings.save_project(tmp.path()).unwrap();
2053
2054 let json_path = oxi_dir.join("settings.json");
2056 assert!(json_path.exists());
2057 let content = fs::read_to_string(&json_path).unwrap();
2058 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2059 assert!(content.contains("json-theme"));
2060 }
2061
2062 #[test]
2065 fn test_custom_provider_default_api() {
2066 use super::CustomProvider;
2067 let cp = CustomProvider {
2068 name: "test".to_string(),
2069 base_url: "https://api.test.com/v1".to_string(),
2070 api_key_env: "TEST_API_KEY".to_string(),
2071 api: super::default_custom_provider_api(),
2072 };
2073 assert_eq!(cp.api, "openai-completions");
2074 }
2075
2076 #[test]
2077 fn test_custom_provider_toml_deserialize() {
2078 let toml_content = r#"
2079[[custom_providers]]
2080name = "minimax"
2081base_url = "https://api.minimax.chat/v1"
2082api_key_env = "MINIMAX_API_KEY"
2083api = "openai-completions"
2084
2085[[custom_providers]]
2086name = "zai"
2087base_url = "https://api.z.ai/v1"
2088api_key_env = "ZAI_API_KEY"
2089api = "openai-responses"
2090"#;
2091 let settings: Settings = toml::from_str(toml_content).unwrap();
2092 assert_eq!(settings.custom_providers.len(), 2);
2093 assert_eq!(settings.custom_providers[0].name, "minimax");
2094 assert_eq!(
2095 settings.custom_providers[0].base_url,
2096 "https://api.minimax.chat/v1"
2097 );
2098 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2099 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2100 assert_eq!(settings.custom_providers[1].name, "zai");
2101 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2102 }
2103
2104 #[test]
2105 fn test_custom_provider_json_deserialize() {
2106 let json_content = r#"{
2107 "custom_providers": [
2108 {
2109 "name": "minimax",
2110 "base_url": "https://api.minimax.chat/v1",
2111 "api_key_env": "MINIMAX_API_KEY",
2112 "api": "openai-completions"
2113 }
2114 ]
2115 }"#;
2116 let settings: Settings = serde_json::from_str(json_content).unwrap();
2117 assert_eq!(settings.custom_providers.len(), 1);
2118 assert_eq!(settings.custom_providers[0].name, "minimax");
2119 }
2120
2121 #[test]
2122 fn test_custom_provider_toml_roundtrip() {
2123 let mut settings = Settings::default();
2124 settings.custom_providers.push(super::CustomProvider {
2125 name: "test".to_string(),
2126 base_url: "https://api.test.com/v1".to_string(),
2127 api_key_env: "TEST_API_KEY".to_string(),
2128 api: "openai-completions".to_string(),
2129 });
2130
2131 let toml_str = toml::to_string_pretty(&settings).unwrap();
2132 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2133 assert_eq!(parsed.custom_providers.len(), 1);
2134 assert_eq!(parsed.custom_providers[0].name, "test");
2135 assert_eq!(
2136 parsed.custom_providers[0].base_url,
2137 "https://api.test.com/v1"
2138 );
2139 }
2140
2141 #[test]
2142 fn test_custom_provider_defaults_empty() {
2143 let settings = Settings::default();
2144 assert!(settings.custom_providers.is_empty());
2145 }
2146
2147 #[test]
2148 fn test_custom_provider_layer_file() {
2149 let base = Settings::default();
2150
2151 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2152 let toml_content = r#"
2153[[custom_providers]]
2154name = "my-provider"
2155base_url = "https://api.my-provider.com/v1"
2156api_key_env = "MY_PROVIDER_API_KEY"
2157"#;
2158 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2159
2160 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2161 assert_eq!(merged.custom_providers.len(), 1);
2162 assert_eq!(merged.custom_providers[0].name, "my-provider");
2163 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2165 }
2166}