1use crate::symbols::GlyphSet;
22use anyhow::{Context, Result};
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 = "OXICODE_";
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 #[serde(default)]
354 pub hooks: Vec<oxicode_sdk::ports::HookSpec>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct AdvisorSettings {
365 #[serde(default = "default_false")]
367 pub enabled: bool,
368 #[serde(default = "default_advisor_sync_backlog")]
372 pub sync_backlog: String,
373 #[serde(default)]
377 pub immune_turns: u64,
378}
379
380impl Default for AdvisorSettings {
381 fn default() -> Self {
382 Self {
383 enabled: false,
384 sync_backlog: default_advisor_sync_backlog(),
385 immune_turns: 0,
386 }
387 }
388}
389
390fn default_advisor_sync_backlog() -> String {
391 "off".to_string()
392}
393
394fn default_theme() -> String {
395 "default".to_string()
396}
397
398fn default_thinking_level() -> ThinkingLevel {
399 ThinkingLevel::Medium
400}
401
402fn default_session_history_size() -> usize {
403 100
404}
405
406fn default_true() -> bool {
407 true
408}
409
410fn default_false() -> bool {
411 false
412}
413
414fn default_ttsr_mode() -> String {
415 "prose_only".to_string()
416}
417
418fn default_tool_timeout() -> u64 {
419 120
420}
421
422fn default_memory_backend() -> Option<String> {
423 None
424}
425
426fn default_embedding_provider() -> String {
427 "none".to_string()
428}
429
430fn default_embedding_model() -> String {
431 String::new()
432}
433
434fn default_embedding_api_key_env() -> String {
435 "OPENAI_API_KEY".to_string()
436}
437
438impl Default for Settings {
439 fn default() -> Self {
440 Self {
441 version: SETTINGS_VERSION,
442 thinking_level: ThinkingLevel::Medium,
443 theme: default_theme(),
444 glyph_set: GlyphSet::default(),
445 last_used_model: None,
446 last_used_provider: None,
447 default_model: None,
448 default_provider: None,
449 max_tokens: None,
450 temperature: None,
451 default_temperature: None,
452 max_response_tokens: None,
453 session_history_size: default_session_history_size(),
454 session_dir: None,
455 extensions_enabled: true,
456 auto_compaction: true,
457 disabled_tools: Vec::new(),
458 tool_timeout_seconds: default_tool_timeout(),
459 ask_timeout_secs: 0,
460 extensions: Vec::new(),
461 skills: Vec::new(),
462 prompts: Vec::new(),
463 themes: Vec::new(),
464 custom_providers: Vec::new(),
465 dynamic_models: HashMap::new(),
466 keybindings: HashMap::new(),
467 edit_format: EditFormat::default(),
468 memory_enabled: false,
469 memory_db_path: None,
470 mnemopi_engine: false,
471 memory_backend: default_memory_backend(),
472 memory_llm_extract: false,
473 memory_llm_extract_model: String::new(),
474 embedding_provider: default_embedding_provider(),
475 embedding_base_url: None,
476 embedding_api_key_env: default_embedding_api_key_env(),
477 embedding_model: default_embedding_model(),
478 todo_panel_enabled: true,
479 agent_hub_enabled: true,
480 snapcompact_enabled: false,
481 advisor: AdvisorSettings::default(),
482 mermaid_render_enabled: true,
483 commit_tool_enabled: false,
484 ttsr_enabled: false,
485 ttsr_interrupt_mode: default_ttsr_mode(),
486 model_roles: HashMap::new(),
487 hooks: Vec::new(),
488 }
489 }
490}
491
492impl Settings {
493 pub fn settings_dir() -> Result<PathBuf> {
497 let base = dirs::home_dir().context("Cannot determine home directory")?;
498 Ok(base.join(".oxicode"))
499 }
500
501 pub fn settings_toml_path() -> Result<PathBuf> {
503 Ok(Self::settings_dir()?.join("settings.toml"))
504 }
505
506 pub fn settings_json_path() -> Result<PathBuf> {
508 Ok(Self::settings_dir()?.join("settings.json"))
509 }
510
511 pub fn settings_path() -> Result<PathBuf> {
518 let json_path = Self::settings_json_path()?;
519 let toml_path = Self::settings_toml_path()?;
520
521 if json_path.exists() && toml_path.exists() {
522 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
524 return Ok(json_path);
525 }
526
527 if json_path.exists() {
528 return Ok(json_path);
529 }
530
531 if toml_path.exists() {
532 return Ok(toml_path);
533 }
534
535 Ok(json_path)
537 }
538
539 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
544 let json_path = Self::settings_json_path()?;
545 let toml_path = Self::settings_toml_path()?;
546
547 let (primary, secondary) = if prefer_json {
548 (&json_path, &toml_path)
549 } else {
550 (&toml_path, &json_path)
551 };
552
553 if primary.exists() {
554 return Ok(primary.clone());
555 }
556
557 if secondary.exists() {
558 return Ok(secondary.clone());
559 }
560
561 Ok(primary.clone())
563 }
564
565 pub fn detect_format(path: &Path) -> SettingsFormat {
567 match path.extension().and_then(|e| e.to_str()) {
568 Some("json") => SettingsFormat::Json,
569 Some("toml") => SettingsFormat::Toml,
570 _ => SettingsFormat::Json, }
572 }
573
574 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
579 let mut dir = start_dir.to_path_buf();
580 loop {
581 let json_candidate = dir.join(".oxicode").join("settings.json");
583 if json_candidate.exists() {
584 return Some(json_candidate);
585 }
586
587 let toml_candidate = dir.join(".oxicode").join("settings.toml");
588 if toml_candidate.exists() {
589 return Some(toml_candidate);
590 }
591
592 if !dir.pop() {
593 return None;
594 }
595 }
596 }
597
598 pub fn effective_session_dir(&self) -> Result<PathBuf> {
602 if let Some(ref dir) = self.session_dir {
603 return Ok(dir.clone());
604 }
605 Ok(Self::settings_dir()?.join("sessions"))
606 }
607
608 pub fn load() -> Result<Self> {
626 Self::load_from_cwd()
627 }
628
629 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
635 Self::load_from_with(dir, None)
636 }
637
638 pub fn load_from_with(
657 dir: &std::path::Path,
658 global_override: Option<&std::path::Path>,
659 ) -> Result<Self> {
660 let mut settings = Settings::default();
662
663 let resolved_global: Option<std::path::PathBuf> = match global_override {
666 Some(p) => Some(p.to_path_buf()),
667 None => Self::settings_path().ok(),
668 };
669 if let Some(ref gp) = resolved_global
670 && gp.exists()
671 {
672 settings = Self::layer_file(&settings, gp)?;
673 }
674
675 if let Some(project_path) = Self::find_project_settings(dir) {
677 settings = Self::layer_file(&settings, &project_path)?;
678 }
679
680 settings.apply_env();
682
683 settings = Self::migrate(settings)?;
685
686 Ok(settings)
689 }
690
691 pub fn load_from_cwd() -> Result<Self> {
693 let cwd = env::current_dir().context("Cannot determine current directory")?;
694 Self::load_from(&cwd)
695 }
696
697 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
703 let content = fs::read_to_string(path)
704 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
705
706 let format = Self::detect_format(path);
707 let overlay: serde_json::Value = match format {
708 SettingsFormat::Toml => {
709 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
710 format!("Failed to parse TOML settings from {}", path.display())
711 })?;
712 toml_value_to_json(toml_value)
714 }
715 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
716 format!("Failed to parse JSON settings from {}", path.display())
717 })?,
718 };
719
720 let base_json =
724 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
725
726 let merged = merge_json_values(base_json, overlay);
727 let result: Settings =
728 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
729
730 Ok(result)
731 }
732
733 #[allow(dead_code)]
758 pub fn apply_env(&mut self) {
759 }
763
764 #[allow(dead_code)]
770 pub fn from_env() -> Self {
771 Self::default()
772 }
773
774 pub fn save(&self) -> Result<()> {
781 let dir = Self::settings_dir()?;
782 let path = Self::settings_path()?;
783
784 if !dir.exists() {
785 fs::create_dir_all(&dir).with_context(|| {
786 format!("Failed to create settings directory {}", dir.display())
787 })?;
788 }
789
790 let format = Self::detect_format(&path);
791 let content = Self::serialize_for_format(self, format)?;
792
793 let tmp_path = path.with_extension("tmp");
795 fs::write(&tmp_path, &content)
796 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
797 fs::rename(&tmp_path, &path)
798 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
799
800 Ok(())
801 }
802
803 pub fn save_to(&self, path: &Path) -> Result<()> {
805 if let Some(parent) = path.parent()
806 && !parent.exists()
807 {
808 fs::create_dir_all(parent)
809 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
810 }
811
812 let format = Self::detect_format(path);
813 let content = Self::serialize_for_format(self, format)?;
814
815 let tmp_path = path.with_extension("tmp");
817 fs::write(&tmp_path, &content)
818 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
819 fs::rename(&tmp_path, path)
820 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
821
822 Ok(())
823 }
824
825 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
829 let dir = project_dir.join(".oxicode");
830
831 if !dir.exists() {
832 fs::create_dir_all(&dir).with_context(|| {
833 format!(
834 "Failed to create project settings directory {}",
835 dir.display()
836 )
837 })?;
838 }
839
840 let json_path = dir.join("settings.json");
842 let toml_path = dir.join("settings.toml");
843
844 let path = if json_path.exists() {
845 &json_path
846 } else if toml_path.exists() {
847 &toml_path
848 } else {
849 &json_path
851 };
852
853 let format = Self::detect_format(path);
854 let content = Self::serialize_for_format(self, format)?;
855
856 let tmp_path = path.with_extension("tmp");
858 fs::write(&tmp_path, &content)
859 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
860 fs::rename(&tmp_path, path)
861 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
862
863 Ok(())
864 }
865
866 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
868 match format {
869 SettingsFormat::Toml => {
870 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
871 }
872 SettingsFormat::Json => serde_json::to_string_pretty(settings)
873 .context("Failed to serialize settings to JSON"),
874 }
875 }
876
877 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
879 match format {
880 SettingsFormat::Toml => {
881 toml::from_str(content).context("Failed to parse TOML settings")
882 }
883 SettingsFormat::Json => {
884 serde_json::from_str(content).context("Failed to parse JSON settings")
885 }
886 }
887 }
888
889 pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
898 if let Some(m) = model {
899 self.last_used_model = Some(m);
900 }
901 if let Some(p) = provider {
902 self.last_used_provider = Some(p);
903 }
904 }
905
906 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
909 cli_model.map(String::from).or_else(|| {
910 let model = self.last_used_model.as_ref()?;
915 if model.contains('/') {
916 Some(model.clone())
918 } else if let Some(ref provider) = self.last_used_provider {
919 Some(format!("{}/{}", provider, model))
921 } else {
922 Some(model.clone())
923 }
924 })
925 }
926
927 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
930 cli_provider
931 .map(String::from)
932 .or_else(|| self.last_used_provider.clone())
933 }
934
935 pub fn effective_temperature(&self) -> Option<f64> {
938 self.default_temperature
939 .or(self.temperature.map(|t| t as f64))
940 }
941
942 pub fn effective_max_tokens(&self) -> Option<usize> {
945 self.max_response_tokens
946 .or(self.max_tokens.map(|t| t as usize))
947 }
948
949 pub fn save_last_used(model_id: &str) {
955 if let Ok(mut settings) = Self::load() {
956 if let Some((provider, model)) = model_id.split_once('/') {
957 settings.last_used_provider = Some(provider.to_string());
958 settings.last_used_model = Some(model.to_string());
959 } else {
960 settings.last_used_model = Some(model_id.to_string());
961 }
962 let _ = settings.save();
963 }
964 }
965
966 pub fn save_theme(&mut self, name: &str) -> Result<()> {
968 self.theme = name.to_string();
969 self.save()
970 }
971
972 pub fn get_theme_name(&self) -> String {
974 if self.theme.is_empty() || self.theme == "default" {
975 "oxi".to_string()
976 } else {
977 self.theme.clone()
978 }
979 }
980
981 fn migrate(settings: Settings) -> Result<Settings> {
995 let mut settings = settings;
996
997 match settings.version {
998 SETTINGS_VERSION => {
999 }
1001 0 => {
1002 if settings.tool_timeout_seconds == 0 {
1005 settings.tool_timeout_seconds = default_tool_timeout();
1006 }
1007 settings.version = SETTINGS_VERSION;
1008
1009 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1010 }
1011 1 | 2 => {
1012 settings.version = SETTINGS_VERSION;
1016 tracing::info!(
1017 "Migrated settings from version {} to {}",
1018 settings.version,
1019 SETTINGS_VERSION
1020 );
1021 }
1022 3 => {
1023 if let Some(model) = settings.default_model.take() {
1026 if let Some((provider, model_name)) = model.split_once('/') {
1027 settings.last_used_provider = Some(provider.to_string());
1028 settings.last_used_model = Some(model_name.to_string());
1029 } else {
1030 settings.last_used_model = Some(model);
1031 }
1032 }
1033 settings.version = SETTINGS_VERSION;
1034 tracing::info!(
1035 "Migrated settings from version 3 to {} (default_model → last_used_model)",
1036 SETTINGS_VERSION
1037 );
1038 }
1039 4 => {
1040 settings.version = SETTINGS_VERSION;
1042 tracing::info!("Migrated settings from version 4 to {}", SETTINGS_VERSION);
1043 }
1044 5 => {
1045 settings.version = SETTINGS_VERSION;
1047 tracing::info!("Migrated settings from version 5 to {}", SETTINGS_VERSION);
1048 }
1049 6 => {
1050 settings.version = SETTINGS_VERSION;
1053 tracing::info!(
1054 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1055 SETTINGS_VERSION
1056 );
1057 }
1058 7 => {
1059 settings.version = SETTINGS_VERSION;
1062 tracing::info!(
1063 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1064 SETTINGS_VERSION
1065 );
1066 }
1067 8 => {
1068 settings.version = SETTINGS_VERSION;
1071 tracing::info!(
1072 "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1073 SETTINGS_VERSION
1074 );
1075 }
1076 v if v > SETTINGS_VERSION => {
1077 anyhow::bail!(
1079 "Settings version {} is newer than supported version {}. \
1080 Please update oxicode.",
1081 v,
1082 SETTINGS_VERSION
1083 );
1084 }
1085 v => {
1086 tracing::warn!(
1088 "Unknown settings version {}, attempting migration to {}",
1089 v,
1090 SETTINGS_VERSION
1091 );
1092 settings.version = SETTINGS_VERSION;
1093 }
1094 }
1095
1096 Ok(settings)
1097 }
1098}
1099
1100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1104pub enum SettingsFormat {
1105 #[default]
1107 Json,
1108 Toml,
1110}
1111
1112impl SettingsFormat {
1113 pub fn extension(&self) -> &'static str {
1115 match self {
1116 SettingsFormat::Json => "json",
1117 SettingsFormat::Toml => "toml",
1118 }
1119 }
1120}
1121
1122fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1126 match toml {
1127 toml::Value::String(s) => serde_json::Value::String(s),
1128 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1129 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1130 .map(serde_json::Value::Number)
1131 .unwrap_or(serde_json::Value::Null),
1132 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1133 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1134 toml::Value::Array(arr) => {
1135 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1136 }
1137 toml::Value::Table(table) => {
1138 let obj = table
1139 .into_iter()
1140 .map(|(k, v)| (k, toml_value_to_json(v)))
1141 .collect();
1142 serde_json::Value::Object(obj)
1143 }
1144 }
1145}
1146
1147fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1149 match (base, override_) {
1150 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1152 let mut result = base_map;
1153 for (key, override_value) in override_map {
1154 let base_value = result.remove(&key);
1155 let merged = match base_value {
1156 Some(base_v) => merge_json_values(base_v, override_value),
1157 None => override_value,
1158 };
1159 result.insert(key, merged);
1160 }
1161 serde_json::Value::Object(result)
1162 }
1163 (_, override_) => override_,
1165 }
1166}
1167
1168pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1170 match s.to_lowercase().as_str() {
1171 "off" | "none" => Some(ThinkingLevel::Off),
1172 "minimal" => Some(ThinkingLevel::Minimal),
1173 "low" => Some(ThinkingLevel::Low),
1174 "medium" | "standard" => Some(ThinkingLevel::Medium),
1175 "high" | "thorough" => Some(ThinkingLevel::High),
1176 "xhigh" => Some(ThinkingLevel::XHigh),
1177 _ => None,
1178 }
1179}
1180
1181#[allow(dead_code)]
1183fn parse_boolish(s: &str) -> Result<bool> {
1184 match s.to_lowercase().as_str() {
1185 "true" | "1" | "yes" | "on" => Ok(true),
1186 "false" | "0" | "no" | "off" => Ok(false),
1187 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1188 }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193 use super::*;
1194 use std::io::Write as IoWrite;
1195 use std::sync::Mutex;
1196
1197 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1200
1201 struct EnvGuard {
1204 saved: Vec<(String, Option<String>)>,
1205 }
1206
1207 impl EnvGuard {
1208 fn new(vars: &[&str]) -> Self {
1209 let saved = vars
1210 .iter()
1211 .map(|&name| {
1212 let old = env::var(name).ok();
1213 unsafe { env::remove_var(name) };
1215 (name.to_string(), old)
1216 })
1217 .collect();
1218 Self { saved }
1219 }
1220 }
1221
1222 impl Drop for EnvGuard {
1223 fn drop(&mut self) {
1224 for (name, old) in self.saved.drain(..) {
1225 match old {
1226 Some(val) => unsafe { env::set_var(&name, val) },
1228 None => unsafe { env::remove_var(&name) },
1229 }
1230 }
1231 }
1232 }
1233
1234 #[test]
1237 fn test_default_settings() {
1238 let settings = Settings::default();
1239 assert_eq!(settings.version, SETTINGS_VERSION);
1240 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1241 assert_eq!(settings.theme, "default");
1242 assert!(settings.last_used_model.is_none());
1243 assert!(settings.last_used_provider.is_none());
1244 assert!(settings.extensions_enabled);
1245 assert!(settings.auto_compaction);
1246 assert_eq!(settings.tool_timeout_seconds, 120);
1247 }
1248
1249 #[test]
1250 fn test_merge_cli() {
1251 let mut settings = Settings::default();
1252 settings.last_used_model = Some("gpt-4o".to_string());
1253
1254 settings.merge_cli(Some("claude".to_string()), None);
1255 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1256
1257 settings.merge_cli(None, Some("google".to_string()));
1258 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1259 }
1260
1261 #[test]
1264 fn test_layer_file_overrides() {
1265 let base = Settings::default();
1266
1267 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1268 let toml_content = r#"
1269last_used_model = "openai/gpt-4o"
1270theme = "dracula"
1271"#;
1272 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1273
1274 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1275 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1276 assert_eq!(merged.theme, "dracula");
1277 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1279 assert!(merged.extensions_enabled);
1280 }
1281
1282 #[test]
1283 fn test_layer_file_preserves_unset() {
1284 let mut base = Settings::default();
1285 base.last_used_provider = Some("deepseek".to_string());
1286
1287 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1288 let toml_content = "theme = \"monokai\"\n";
1290 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1291
1292 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1293 assert_eq!(merged.theme, "monokai");
1294 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1295 }
1296
1297 #[test]
1298 fn test_load_from_dir_with_project_config() {
1299 let _guard = EnvGuard::new(&[
1300 "OXICODE_MODEL",
1301 "OXICODE_PROVIDER",
1302 "OXICODE_THEME",
1303 "OXICODE_TOOL_TIMEOUT",
1304 "OXICODE_TEMPERATURE",
1305 "OXICODE_MAX_TOKENS",
1306 "OXICODE_SESSION_DIR",
1307 "OXICODE_EXTENSIONS_ENABLED",
1308 ]);
1309 let tmp = tempfile::tempdir().unwrap();
1310 let oxicode_dir = tmp.path().join(".oxicode");
1311 fs::create_dir_all(&oxicode_dir).unwrap();
1312 let settings_path = oxicode_dir.join("settings.toml");
1313 fs::write(
1315 &settings_path,
1316 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1317 )
1318 .unwrap();
1319
1320 let settings = Settings::load_from(tmp.path()).unwrap();
1321 assert_eq!(
1323 settings.last_used_model,
1324 Some("gemini-2.0-flash".to_string())
1325 );
1326 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1327 }
1328
1329 #[test]
1330 fn test_load_from_dir_no_config() {
1331 let _guard = EnvGuard::new(&[
1333 "OXICODE_MODEL",
1334 "OXICODE_PROVIDER",
1335 "OXICODE_THEME",
1336 "OXICODE_TOOL_TIMEOUT",
1337 "OXICODE_TEMPERATURE",
1338 "OXICODE_MAX_TOKENS",
1339 "OXICODE_SESSION_DIR",
1340 "OXICODE_EXTENSIONS_ENABLED",
1341 ]);
1342 let tmp = tempfile::tempdir().unwrap();
1343 let global = tmp.path().join("nonexistent-settings.json");
1348 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1349 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1350 }
1351 #[test]
1352 fn test_from_env() {
1353 let _guard = EnvGuard::new(&[
1356 "OXICODE_MODEL",
1358 "OXICODE_THEME",
1359 "OXICODE_TOOL_TIMEOUT",
1360 "OXICODE_PROVIDER",
1361 "OXICODE_DEFAULT_MODEL",
1362 ]);
1363
1364 let settings = Settings::from_env();
1365 assert_eq!(settings.last_used_model, None);
1367 assert_eq!(settings.theme, "default");
1368 assert_eq!(settings.tool_timeout_seconds, 120);
1369 }
1370
1371 #[test]
1372 fn test_apply_env_boolish() {
1373 let _guard = EnvGuard::new(&["OXICODE_EXTENSIONS_ENABLED"]);
1376 unsafe { env::set_var("OXICODE_EXTENSIONS_ENABLED", "0") };
1377
1378 let mut settings = Settings::default();
1379 settings.apply_env();
1380 assert!(settings.extensions_enabled); }
1383
1384 #[test]
1385 fn test_apply_env_temperature() {
1386 let _guard = EnvGuard::new(&["OXICODE_TEMPERATURE"]);
1388 unsafe { env::set_var("OXICODE_TEMPERATURE", "0.7") };
1389
1390 let mut settings = Settings::default();
1391 settings.apply_env();
1392 assert_eq!(settings.default_temperature, None);
1394 }
1395
1396 #[test]
1397 fn test_env_does_not_override_when_unset() {
1398 let _guard = EnvGuard::new(&[
1399 "OXICODE_MODEL",
1400 "OXICODE_PROVIDER",
1401 "OXICODE_THEME",
1402 "OXICODE_TEMPERATURE",
1403 ]);
1404 let settings = Settings::from_env();
1405 assert!(settings.last_used_model.is_none());
1406 assert!(settings.last_used_provider.is_none());
1407 }
1408
1409 #[test]
1410 fn test_parse_thinking_level() {
1411 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1412 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1413 assert_eq!(
1414 parse_thinking_level("MINIMAL"),
1415 Some(ThinkingLevel::Minimal)
1416 );
1417 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1418 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1419 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1420 assert_eq!(
1421 parse_thinking_level("Standard"),
1422 Some(ThinkingLevel::Medium)
1423 );
1424 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1425 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1426 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1427 assert_eq!(parse_thinking_level("invalid"), None);
1428 }
1429
1430 #[test]
1431 fn test_parse_boolish() {
1432 assert!(parse_boolish("true").unwrap());
1433 assert!(parse_boolish("1").unwrap());
1434 assert!(parse_boolish("yes").unwrap());
1435 assert!(parse_boolish("ON").unwrap());
1436 assert!(!parse_boolish("false").unwrap());
1437 assert!(!parse_boolish("0").unwrap());
1438 assert!(!parse_boolish("no").unwrap());
1439 assert!(!parse_boolish("OFF").unwrap());
1440 assert!(parse_boolish("maybe").is_err());
1441 }
1442
1443 #[test]
1446 fn test_effective_model_returns_last_used() {
1447 let mut settings = Settings::default();
1448 settings.last_used_model = Some("openai/gpt-4o".to_string());
1449 assert_eq!(
1450 settings.effective_model(None),
1451 Some("openai/gpt-4o".to_string())
1452 );
1453 }
1454
1455 #[test]
1456 fn test_effective_model_cli_overrides() {
1457 let mut settings = Settings::default();
1458 settings.last_used_model = Some("openai/gpt-4o".to_string());
1459 assert_eq!(
1460 settings.effective_model(Some("anthropic/claude-3")),
1461 Some("anthropic/claude-3".to_string())
1462 );
1463 }
1464
1465 #[test]
1466 fn test_effective_model_none_when_unset() {
1467 let settings = Settings::default();
1468 assert_eq!(settings.effective_model(None), None);
1469 }
1470
1471 #[test]
1472 fn test_effective_model_falls_back_to_last_used() {
1473 let mut settings = Settings::default();
1474 settings.last_used_model = Some("anthropic/claude-3".to_string());
1475 assert_eq!(
1476 settings.effective_model(None),
1477 Some("anthropic/claude-3".to_string())
1478 );
1479 }
1480
1481 #[test]
1482 fn test_effective_model_returns_none_when_nothing_set() {
1483 let settings = Settings::default();
1484 assert_eq!(settings.effective_model(None), None);
1485 }
1486
1487 #[test]
1488 fn test_effective_temperature_prefers_f64() {
1489 let mut settings = Settings::default();
1490 settings.temperature = Some(0.5);
1491 settings.default_temperature = Some(0.7);
1492 assert_eq!(settings.effective_temperature(), Some(0.7));
1493 }
1494
1495 #[test]
1496 fn test_effective_temperature_falls_back_to_f32() {
1497 let mut settings = Settings::default();
1498 settings.temperature = Some(0.5);
1499 assert_eq!(settings.effective_temperature(), Some(0.5));
1500 }
1501
1502 #[test]
1503 fn test_effective_max_tokens_prefers_usize() {
1504 let mut settings = Settings::default();
1505 settings.max_tokens = Some(1024);
1506 settings.max_response_tokens = Some(4096);
1507 assert_eq!(settings.effective_max_tokens(), Some(4096));
1508 }
1509
1510 #[test]
1511 fn test_effective_max_tokens_falls_back_to_u32() {
1512 let mut settings = Settings::default();
1513 settings.max_tokens = Some(1024);
1514 assert_eq!(settings.effective_max_tokens(), Some(1024));
1515 }
1516
1517 #[test]
1520 fn test_effective_session_dir_default() {
1521 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1522 let settings = Settings::default();
1523 let dir = settings.effective_session_dir().unwrap();
1524 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1525 }
1526
1527 #[test]
1528 fn test_effective_session_dir_from_field() {
1529 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1530 let mut settings = Settings::default();
1531 settings.session_dir = Some(PathBuf::from("/tmp/oxicode-sessions"));
1532 assert_eq!(
1533 settings.effective_session_dir().unwrap(),
1534 PathBuf::from("/tmp/oxicode-sessions")
1535 );
1536 }
1537
1538 #[test]
1539 fn test_effective_session_dir_env_disabled() {
1540 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1543 unsafe { env::set_var("OXICODE_SESSION_DIR", "/tmp/env-sessions") };
1544 let settings = Settings::default();
1545 let dir = settings.effective_session_dir().unwrap();
1547 assert!(
1548 dir.ends_with("sessions"),
1549 "expected default sessions dir, got: {:?}",
1550 dir
1551 );
1552 }
1553
1554 #[test]
1557 fn test_migration_v0_to_v1() {
1558 let mut settings = Settings::default();
1559 settings.version = 0;
1560 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1563 assert_eq!(migrated.version, SETTINGS_VERSION);
1564 assert_eq!(migrated.tool_timeout_seconds, 120);
1565 }
1566
1567 #[test]
1568 fn test_migration_already_current() {
1569 let settings = Settings::default();
1570 let migrated = Settings::migrate(settings).unwrap();
1571 assert_eq!(migrated.version, SETTINGS_VERSION);
1572 }
1573
1574 #[test]
1575 fn test_migration_v3_to_v4_splits_model() {
1576 let mut settings = Settings::default();
1577 settings.version = 3;
1578 settings.default_model = Some("openai/gpt-4o".to_string());
1579 settings.default_provider = None;
1580
1581 let migrated = Settings::migrate(settings).unwrap();
1582 assert_eq!(migrated.version, SETTINGS_VERSION);
1583 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1584 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1585 }
1586
1587 #[test]
1588 fn test_migration_v3_no_slash_keeps_model() {
1589 let mut settings = Settings::default();
1590 settings.version = 3;
1591 settings.default_model = Some("bare-model-name".to_string());
1592
1593 let migrated = Settings::migrate(settings).unwrap();
1594 assert_eq!(migrated.version, SETTINGS_VERSION);
1595 assert_eq!(
1596 migrated.last_used_model,
1597 Some("bare-model-name".to_string())
1598 );
1599 }
1600
1601 #[test]
1602 fn test_migration_future_version_fails() {
1603 let mut settings = Settings::default();
1604 settings.version = 9999;
1605 assert!(Settings::migrate(settings).is_err());
1606 }
1607
1608 #[test]
1609 fn test_default_glyph_set_is_unicode() {
1610 let settings = Settings::default();
1611 assert_eq!(
1612 settings.glyph_set,
1613 GlyphSet::Unicode,
1614 "glyph_set must default to Unicode"
1615 );
1616 }
1617
1618 #[test]
1619 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1620 let mut settings = Settings::default();
1623 settings.version = 7;
1624 settings.glyph_set = GlyphSet::default();
1626
1627 let migrated = Settings::migrate(settings).unwrap();
1628 assert_eq!(migrated.version, SETTINGS_VERSION);
1629 assert_eq!(
1630 migrated.glyph_set,
1631 GlyphSet::Unicode,
1632 "v7 → v8 migration must default glyph_set to unicode"
1633 );
1634 }
1635
1636 #[test]
1637 fn test_glyph_set_persists_through_roundtrip() {
1638 let mut original = Settings::default();
1642 original.glyph_set = GlyphSet::Nerd;
1643 let content = toml::to_string_pretty(&original).unwrap();
1644 assert!(
1645 content.contains("glyph_set = \"nerd\""),
1646 "nerd preset must serialize to snake_case; got:\n{content}"
1647 );
1648 let loaded: Settings = toml::from_str(&content).unwrap();
1649 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1650 original.glyph_set = GlyphSet::Unicode;
1652 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1653 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1654 }
1655
1656 #[test]
1657 fn test_save_and_load_roundtrip() {
1658 let tmp = tempfile::tempdir().unwrap();
1659 let settings_path = tmp.path().join("settings.toml");
1660
1661 let mut original = Settings::default();
1662 original.last_used_model = Some("gpt-4o".to_string());
1663 original.last_used_provider = Some("openai".to_string());
1664 original.theme = "dracula".to_string();
1665 original.tool_timeout_seconds = 60;
1666
1667 let content = toml::to_string_pretty(&original).unwrap();
1669 fs::write(&settings_path, &content).unwrap();
1670
1671 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1673 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1674
1675 assert_eq!(loaded.last_used_model, original.last_used_model);
1676 assert_eq!(loaded.theme, original.theme);
1677 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1678 }
1679
1680 #[test]
1681 fn test_toml_roundtrip_preserves_new_fields() {
1682 let mut settings = Settings::default();
1683 settings.default_temperature = Some(0.8);
1684 settings.max_response_tokens = Some(8192);
1685 settings.auto_compaction = false;
1686 settings.extensions_enabled = false;
1687 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1688
1689 let toml_str = toml::to_string_pretty(&settings).unwrap();
1690 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1691
1692 assert_eq!(parsed.default_temperature, Some(0.8));
1693 assert_eq!(parsed.max_response_tokens, Some(8192));
1694 assert!(!parsed.auto_compaction);
1695 assert!(!parsed.extensions_enabled);
1696 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1697 }
1698
1699 #[test]
1702 fn test_json_roundtrip() {
1703 let mut settings = Settings::default();
1704 settings.last_used_model = Some("gpt-4o".to_string());
1705 settings.last_used_provider = Some("openai".to_string());
1706 settings.theme = "dracula".to_string();
1707 settings.tool_timeout_seconds = 60;
1708 settings.default_temperature = Some(0.8);
1709 settings.max_response_tokens = Some(8192);
1710
1711 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1712 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1713
1714 assert_eq!(parsed.last_used_model, settings.last_used_model);
1715 assert_eq!(parsed.theme, settings.theme);
1716 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1717 assert_eq!(parsed.default_temperature, settings.default_temperature);
1718 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1719 }
1720
1721 #[test]
1722 fn test_json_serialize_for_format() {
1723 let mut settings = Settings::default();
1724 settings.last_used_model = Some("claude-3".to_string());
1725 settings.last_used_provider = Some("anthropic".to_string());
1726 settings.thinking_level = ThinkingLevel::Minimal;
1727
1728 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1729 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1730
1731 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1732 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1733 }
1734
1735 #[test]
1736 fn test_toml_serialize_for_format() {
1737 let mut settings = Settings::default();
1738 settings.last_used_model = Some("gemini-pro".to_string());
1739 settings.last_used_provider = Some("google".to_string());
1740 settings.thinking_level = ThinkingLevel::High;
1741
1742 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1743 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1744
1745 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1746 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1747 }
1748
1749 #[test]
1750 fn test_parse_from_str_json() {
1751 let json_content = r#"{
1752 "last_used_model": "gpt-4",
1753 "last_used_provider": "openai",
1754 "theme": "nord",
1755 "tool_timeout_seconds": 90
1756 }"#;
1757
1758 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1759 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1760 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1761 assert_eq!(settings.theme, "nord");
1762 assert_eq!(settings.tool_timeout_seconds, 90);
1763 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1765 assert!(settings.extensions_enabled);
1766 }
1767
1768 #[test]
1769 fn test_parse_from_str_toml() {
1770 let toml_content = r#"
1771last_used_model = "claude-opus"
1772last_used_provider = "anthropic"
1773theme = "monokai"
1774tool_timeout_seconds = 45
1775"#;
1776
1777 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1778 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1779 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1780 assert_eq!(settings.theme, "monokai");
1781 assert_eq!(settings.tool_timeout_seconds, 45);
1782 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1783 }
1784
1785 #[test]
1786 fn test_layer_file_json() {
1787 let base = Settings::default();
1788
1789 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1790 let json_content = r#"{
1791 "last_used_model": "gpt-4o",
1792 "last_used_provider": "openai",
1793 "theme": "dracula",
1794 "auto_compaction": false
1795 }"#;
1796 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1797
1798 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1799 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1800 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1801 assert_eq!(merged.theme, "dracula");
1802 assert!(!merged.auto_compaction);
1803 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1805 assert!(merged.extensions_enabled);
1806 assert_eq!(merged.tool_timeout_seconds, 120);
1807 }
1808
1809 #[test]
1810 fn test_layer_file_json_preserves_unset() {
1811 let mut base = Settings::default();
1812 base.last_used_provider = Some("deepseek".to_string());
1813
1814 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1815 let json_content = r#"{ "theme": "nord" }"#;
1816 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1817
1818 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1819 assert_eq!(merged.theme, "nord");
1820 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1821 }
1822
1823 #[test]
1824 fn test_save_to_json() {
1825 let tmp = tempfile::tempdir().unwrap();
1826 let settings_path = tmp.path().join("settings.json");
1827
1828 let mut settings = Settings::default();
1829 settings.last_used_model = Some("gpt-4o".to_string());
1830 settings.last_used_provider = Some("openai".to_string());
1831 settings.theme = "dracula".to_string();
1832 settings.tool_timeout_seconds = 60;
1833
1834 settings.save_to(&settings_path).unwrap();
1835
1836 let content = fs::read_to_string(&settings_path).unwrap();
1838 let parsed: Settings = serde_json::from_str(&content).unwrap();
1839 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1840 assert_eq!(parsed.theme, "dracula");
1841 assert_eq!(parsed.tool_timeout_seconds, 60);
1842 }
1843
1844 #[test]
1845 fn test_save_to_toml() {
1846 let tmp = tempfile::tempdir().unwrap();
1847 let settings_path = tmp.path().join("settings.toml");
1848
1849 let mut settings = Settings::default();
1850 settings.last_used_model = Some("gemini-pro".to_string());
1851 settings.last_used_provider = Some("google".to_string());
1852 settings.theme = "monokai".to_string();
1853 settings.tool_timeout_seconds = 90;
1854
1855 settings.save_to(&settings_path).unwrap();
1856
1857 let content = fs::read_to_string(&settings_path).unwrap();
1859 let parsed: Settings = toml::from_str(&content).unwrap();
1860 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1861 assert_eq!(parsed.theme, "monokai");
1862 assert_eq!(parsed.tool_timeout_seconds, 90);
1863 }
1864
1865 #[test]
1866 fn test_load_from_dir_with_json_project_config() {
1867 let _guard = EnvGuard::new(&[
1868 "OXICODE_MODEL",
1869 "OXICODE_PROVIDER",
1870 "OXICODE_THEME",
1871 "OXICODE_TOOL_TIMEOUT",
1872 "OXICODE_TEMPERATURE",
1873 "OXICODE_MAX_TOKENS",
1874 "OXICODE_SESSION_DIR",
1875 "OXICODE_EXTENSIONS_ENABLED",
1876 ]);
1877 let tmp = tempfile::tempdir().unwrap();
1878 let oxicode_dir = tmp.path().join(".oxicode");
1879 fs::create_dir_all(&oxicode_dir).unwrap();
1880 let settings_path = oxicode_dir.join("settings.json");
1881 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1883 fs::write(&settings_path, json_content).unwrap();
1884
1885 let settings = Settings::load_from(tmp.path()).unwrap();
1886 assert_eq!(
1888 settings.last_used_model,
1889 Some("gemini-2.0-flash".to_string())
1890 );
1891 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1892 }
1893
1894 #[test]
1895 fn test_find_project_settings_json_priority() {
1896 let tmp = tempfile::tempdir().unwrap();
1897 let oxicode_dir = tmp.path().join(".oxicode");
1898 fs::create_dir_all(&oxicode_dir).unwrap();
1899
1900 let json_path = oxicode_dir.join("settings.json");
1902 let toml_path = oxicode_dir.join("settings.toml");
1903 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1904 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1905
1906 let found = Settings::find_project_settings(tmp.path());
1908 assert!(found.is_some());
1909 assert_eq!(
1910 found.unwrap().file_name().unwrap().to_str().unwrap(),
1911 "settings.json"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_find_project_settings_json_only() {
1917 let tmp = tempfile::tempdir().unwrap();
1918 let oxicode_dir = tmp.path().join(".oxicode");
1919 fs::create_dir_all(&oxicode_dir).unwrap();
1920
1921 let json_path = oxicode_dir.join("settings.json");
1922 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1923
1924 let found = Settings::find_project_settings(tmp.path());
1925 assert!(found.is_some());
1926 assert_eq!(
1927 found.unwrap().file_name().unwrap().to_str().unwrap(),
1928 "settings.json"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_find_project_settings_toml_fallback() {
1934 let tmp = tempfile::tempdir().unwrap();
1935 let oxicode_dir = tmp.path().join(".oxicode");
1936 fs::create_dir_all(&oxicode_dir).unwrap();
1937
1938 let toml_path = oxicode_dir.join("settings.toml");
1939 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1940
1941 let found = Settings::find_project_settings(tmp.path());
1942 assert!(found.is_some());
1943 assert_eq!(
1944 found.unwrap().file_name().unwrap().to_str().unwrap(),
1945 "settings.toml"
1946 );
1947 }
1948
1949 #[test]
1950 fn test_detect_format() {
1951 let json_path = PathBuf::from("/test/settings.json");
1952 let toml_path = PathBuf::from("/test/settings.toml");
1953 let unknown_path = PathBuf::from("/test/settings");
1954
1955 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1956 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1957 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1958 }
1960
1961 #[test]
1962 fn test_settings_format_extension() {
1963 assert_eq!(SettingsFormat::Json.extension(), "json");
1964 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1965 }
1966
1967 #[test]
1968 fn test_layer_json_over_toml() {
1969 let tmp = tempfile::tempdir().unwrap();
1971 let oxicode_dir = tmp.path().join(".oxicode");
1972 fs::create_dir_all(&oxicode_dir).unwrap();
1973
1974 let json_path = oxicode_dir.join("settings.json");
1975 let toml_path = oxicode_dir.join("settings.toml");
1976
1977 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
1979 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
1981
1982 let settings = Settings::load_from(tmp.path()).unwrap();
1984 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
1985 }
1986
1987 #[test]
1988 fn test_mixed_format_loading() {
1989 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1991 let toml_content = r#"
1992last_used_model = "loaded-via-toml"
1993theme = "loaded-theme"
1994"#;
1995 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1996
1997 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1998 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
1999 assert_eq!(merged.theme, "loaded-theme");
2000 }
2001
2002 #[test]
2003 fn test_merge_json_values() {
2004 let base = serde_json::json!({
2005 "version": 1,
2006 "theme": "default",
2007 "extensions": ["ext1"],
2008 "nested": {
2009 "a": 1,
2010 "b": 2
2011 }
2012 });
2013
2014 let override_ = serde_json::json!({
2015 "version": 2,
2016 "theme": "dark",
2017 "extensions": ["ext2"],
2018 "nested": {
2019 "b": 20,
2020 "c": 30
2021 }
2022 });
2023
2024 let merged = merge_json_values(base, override_);
2025
2026 assert_eq!(merged["version"], 2);
2027 assert_eq!(merged["theme"], "dark");
2028 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2030 assert_eq!(merged["nested"]["a"], 1);
2032 assert_eq!(merged["nested"]["b"], 20);
2033 assert_eq!(merged["nested"]["c"], 30);
2034 }
2035
2036 #[test]
2037 fn test_save_project_preserves_existing_format() {
2038 let tmp = tempfile::tempdir().unwrap();
2039 let oxicode_dir = tmp.path().join(".oxicode");
2040 fs::create_dir_all(&oxicode_dir).unwrap();
2041
2042 let toml_path = oxicode_dir.join("settings.toml");
2044 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2045
2046 let mut settings = Settings::default();
2047 settings.theme = "new-theme".to_string();
2048 settings.save_project(tmp.path()).unwrap();
2049
2050 let content = fs::read_to_string(&toml_path).unwrap();
2052 assert!(content.contains("new-theme"));
2053 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2054 }
2055
2056 #[test]
2057 fn test_save_project_creates_json_by_default() {
2058 let tmp = tempfile::tempdir().unwrap();
2059 let oxicode_dir = tmp.path().join(".oxicode");
2060 fs::create_dir_all(&oxicode_dir).unwrap();
2061 let mut settings = Settings::default();
2064 settings.theme = "json-theme".to_string();
2065 settings.save_project(tmp.path()).unwrap();
2066
2067 let json_path = oxicode_dir.join("settings.json");
2069 assert!(json_path.exists());
2070 let content = fs::read_to_string(&json_path).unwrap();
2071 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2072 assert!(content.contains("json-theme"));
2073 }
2074
2075 #[test]
2078 fn test_custom_provider_default_api() {
2079 use super::CustomProvider;
2080 let cp = CustomProvider {
2081 name: "test".to_string(),
2082 base_url: "https://api.test.com/v1".to_string(),
2083 api_key_env: "TEST_API_KEY".to_string(),
2084 api: super::default_custom_provider_api(),
2085 };
2086 assert_eq!(cp.api, "openai-completions");
2087 }
2088
2089 #[test]
2090 fn test_custom_provider_toml_deserialize() {
2091 let toml_content = r#"
2092[[custom_providers]]
2093name = "minimax"
2094base_url = "https://api.minimax.chat/v1"
2095api_key_env = "MINIMAX_API_KEY"
2096api = "openai-completions"
2097
2098[[custom_providers]]
2099name = "zai"
2100base_url = "https://api.z.ai/v1"
2101api_key_env = "ZAI_API_KEY"
2102api = "openai-responses"
2103"#;
2104 let settings: Settings = toml::from_str(toml_content).unwrap();
2105 assert_eq!(settings.custom_providers.len(), 2);
2106 assert_eq!(settings.custom_providers[0].name, "minimax");
2107 assert_eq!(
2108 settings.custom_providers[0].base_url,
2109 "https://api.minimax.chat/v1"
2110 );
2111 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2112 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2113 assert_eq!(settings.custom_providers[1].name, "zai");
2114 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2115 }
2116
2117 #[test]
2118 fn test_custom_provider_json_deserialize() {
2119 let json_content = r#"{
2120 "custom_providers": [
2121 {
2122 "name": "minimax",
2123 "base_url": "https://api.minimax.chat/v1",
2124 "api_key_env": "MINIMAX_API_KEY",
2125 "api": "openai-completions"
2126 }
2127 ]
2128 }"#;
2129 let settings: Settings = serde_json::from_str(json_content).unwrap();
2130 assert_eq!(settings.custom_providers.len(), 1);
2131 assert_eq!(settings.custom_providers[0].name, "minimax");
2132 }
2133
2134 #[test]
2135 fn test_custom_provider_toml_roundtrip() {
2136 let mut settings = Settings::default();
2137 settings.custom_providers.push(super::CustomProvider {
2138 name: "test".to_string(),
2139 base_url: "https://api.test.com/v1".to_string(),
2140 api_key_env: "TEST_API_KEY".to_string(),
2141 api: "openai-completions".to_string(),
2142 });
2143
2144 let toml_str = toml::to_string_pretty(&settings).unwrap();
2145 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2146 assert_eq!(parsed.custom_providers.len(), 1);
2147 assert_eq!(parsed.custom_providers[0].name, "test");
2148 assert_eq!(
2149 parsed.custom_providers[0].base_url,
2150 "https://api.test.com/v1"
2151 );
2152 }
2153
2154 #[test]
2155 fn test_custom_provider_defaults_empty() {
2156 let settings = Settings::default();
2157 assert!(settings.custom_providers.is_empty());
2158 }
2159
2160 #[test]
2161 fn test_custom_provider_layer_file() {
2162 let base = Settings::default();
2163
2164 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2165 let toml_content = r#"
2166[[custom_providers]]
2167name = "my-provider"
2168base_url = "https://api.my-provider.com/v1"
2169api_key_env = "MY_PROVIDER_API_KEY"
2170"#;
2171 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2172
2173 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2174 assert_eq!(merged.custom_providers.len(), 1);
2175 assert_eq!(merged.custom_providers[0].name, "my-provider");
2176 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2178 }
2179
2180 #[test]
2181 fn settings_deserialise_hooks_array() {
2182 let toml = r#"
2183 [[hooks]]
2184 event = "PreToolUse"
2185 matcher = "bash|write"
2186 command = "echo pre"
2187 timeout_secs = 10
2188 "#;
2189 let s: Settings = toml::from_str(toml).unwrap();
2190 assert_eq!(s.hooks.len(), 1);
2191 assert_eq!(s.hooks[0].event, oxicode_sdk::ports::HookEvent::PreToolUse);
2192 assert_eq!(s.hooks[0].matcher.as_deref(), Some("bash|write"));
2193 assert_eq!(s.hooks[0].command, "echo pre");
2194 assert_eq!(s.hooks[0].timeout_secs, Some(10));
2195 }
2196
2197 #[test]
2198 fn settings_default_has_no_hooks() {
2199 let s = Settings::default();
2200 assert!(s.hooks.is_empty());
2201 }
2202}