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
100pub(crate) fn default_custom_provider_api() -> String {
101 "openai-completions".to_string()
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum TodoEagerMode {
110 #[default]
112 Off,
113 Preferred,
115 Always,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct Settings {
123 #[serde(default)]
126 pub version: u32,
127
128 #[serde(default = "default_thinking_level")]
131 pub thinking_level: ThinkingLevel,
132 #[serde(default = "default_theme")]
134 pub theme: String,
135
136 #[serde(default)]
143 pub glyph_set: GlyphSet,
144
145 #[serde(default, skip_serializing)]
147 pub default_model: Option<String>,
148
149 #[serde(default, skip_serializing)]
151 pub default_provider: Option<String>,
152
153 #[serde(default)]
156 pub last_used_model: Option<String>,
157
158 #[serde(default)]
160 pub last_used_provider: Option<String>,
161
162 pub max_tokens: Option<u32>,
164
165 pub temperature: Option<f32>,
167
168 pub default_temperature: Option<f64>,
170
171 pub max_response_tokens: Option<usize>,
173
174 #[serde(default = "default_session_history_size")]
177 pub session_history_size: usize,
178
179 pub session_dir: Option<PathBuf>,
181
182 #[serde(default = "default_true")]
185 pub extensions_enabled: bool,
186
187 #[serde(default = "default_true")]
189 pub auto_compaction: bool,
190
191 #[serde(default)]
194 pub disabled_tools: Vec<String>,
195
196 #[serde(default = "default_tool_timeout")]
199 pub tool_timeout_seconds: u64,
200
201 #[serde(default, alias = "questionnaire_timeout_secs")]
204 pub ask_timeout_secs: u64,
205
206 #[serde(default)]
209 pub extensions: Vec<String>,
210
211 #[serde(default)]
213 pub skills: Vec<String>,
214
215 #[serde(default)]
217 pub prompts: Vec<String>,
218
219 #[serde(default)]
221 pub themes: Vec<String>,
222
223 #[serde(default)]
226 pub custom_providers: Vec<CustomProvider>,
227
228 #[serde(default)]
233 pub dynamic_models: HashMap<String, Vec<String>>,
234
235 #[serde(default)]
240 pub keybindings: HashMap<String, Vec<String>>,
241
242 #[serde(default)]
251 pub edit_format: EditFormat,
252
253 #[serde(default = "default_true")]
257 pub todo_panel_enabled: bool,
258
259 #[serde(default)]
262 pub todo_eager_mode: TodoEagerMode,
263
264 #[serde(default = "default_true")]
266 pub todo_reminders_enabled: bool,
267
268 #[serde(default = "default_todo_reminders_max")]
270 pub todo_reminders_max: u32,
271
272 #[serde(default = "default_todo_clear_delay_secs")]
275 pub todo_clear_delay_secs: i64,
276
277 #[serde(default = "default_true")]
280 pub agent_hub_enabled: bool,
281
282 #[serde(default = "default_false")]
285 pub snapcompact_enabled: bool,
286
287 #[serde(default = "default_true")]
290 pub mermaid_render_enabled: bool,
291
292 #[serde(default = "default_true")]
296 pub inline_images: bool,
297
298 #[serde(default = "default_false")]
301 pub commit_tool_enabled: bool,
302
303 #[serde(default = "default_false")]
319 pub bash_pty: bool,
320
321 #[serde(default = "default_true")]
327 pub memory_enabled: bool,
328
329 #[serde(default = "default_false")]
333 pub ttsr_enabled: bool,
334
335 #[serde(default = "default_ttsr_mode")]
337 pub ttsr_interrupt_mode: String,
338
339 #[serde(default)]
350 pub model_roles: HashMap<String, String>,
351 #[serde(default)]
355 pub advisor: AdvisorSettings,
356
357 #[serde(default)]
362 pub hooks: Vec<oxicode_sdk::ports::HookSpec>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct AdvisorSettings {
373 #[serde(default = "default_false")]
375 pub enabled: bool,
376 #[serde(default = "default_advisor_sync_backlog")]
380 pub sync_backlog: String,
381 #[serde(default)]
385 pub immune_turns: u64,
386}
387
388impl Default for AdvisorSettings {
389 fn default() -> Self {
390 Self {
391 enabled: false,
392 sync_backlog: default_advisor_sync_backlog(),
393 immune_turns: 0,
394 }
395 }
396}
397
398fn default_advisor_sync_backlog() -> String {
399 "off".to_string()
400}
401
402fn default_theme() -> String {
403 "default".to_string()
404}
405
406fn default_thinking_level() -> ThinkingLevel {
407 ThinkingLevel::Medium
408}
409
410fn default_session_history_size() -> usize {
411 100
412}
413
414fn default_true() -> bool {
415 true
416}
417
418fn default_false() -> bool {
419 false
420}
421fn default_todo_reminders_max() -> u32 {
422 3
423}
424
425fn default_todo_clear_delay_secs() -> i64 {
426 60
427}
428
429fn default_ttsr_mode() -> String {
430 "prose_only".to_string()
431}
432
433fn default_tool_timeout() -> u64 {
434 120
435}
436
437impl Default for Settings {
438 fn default() -> Self {
439 Self {
440 version: SETTINGS_VERSION,
441 thinking_level: ThinkingLevel::Medium,
442 theme: default_theme(),
443 glyph_set: GlyphSet::default(),
444 last_used_model: None,
445 last_used_provider: None,
446 default_model: None,
447 default_provider: None,
448 max_tokens: None,
449 temperature: None,
450 default_temperature: None,
451 max_response_tokens: None,
452 session_history_size: default_session_history_size(),
453 session_dir: None,
454 extensions_enabled: true,
455 auto_compaction: true,
456 disabled_tools: Vec::new(),
457 tool_timeout_seconds: default_tool_timeout(),
458 ask_timeout_secs: 0,
459 extensions: Vec::new(),
460 skills: Vec::new(),
461 prompts: Vec::new(),
462 themes: Vec::new(),
463 custom_providers: Vec::new(),
464 dynamic_models: HashMap::new(),
465 keybindings: HashMap::new(),
466 edit_format: EditFormat::default(),
467 memory_enabled: true,
468 todo_panel_enabled: true,
469 todo_eager_mode: TodoEagerMode::Off,
470 todo_reminders_enabled: true,
471 todo_reminders_max: default_todo_reminders_max(),
472 todo_clear_delay_secs: default_todo_clear_delay_secs(),
473 agent_hub_enabled: true,
474 snapcompact_enabled: false,
475 advisor: AdvisorSettings::default(),
476 mermaid_render_enabled: true,
477 inline_images: true,
478 commit_tool_enabled: false,
479 bash_pty: false,
480 ttsr_enabled: false,
481 ttsr_interrupt_mode: default_ttsr_mode(),
482 model_roles: HashMap::new(),
483 hooks: Vec::new(),
484 }
485 }
486}
487
488impl Settings {
489 pub fn settings_dir() -> Result<PathBuf> {
493 oxicode_catalog::product_env::home_dir().context("Cannot determine oxicode home directory")
494 }
495
496 pub fn settings_toml_path() -> Result<PathBuf> {
498 Ok(Self::settings_dir()?.join("settings.toml"))
499 }
500
501 pub fn settings_json_path() -> Result<PathBuf> {
503 Ok(Self::settings_dir()?.join("settings.json"))
504 }
505
506 pub fn settings_path() -> Result<PathBuf> {
513 let json_path = Self::settings_json_path()?;
514 let toml_path = Self::settings_toml_path()?;
515
516 if json_path.exists() && toml_path.exists() {
517 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
519 return Ok(json_path);
520 }
521
522 if json_path.exists() {
523 return Ok(json_path);
524 }
525
526 if toml_path.exists() {
527 return Ok(toml_path);
528 }
529
530 Ok(json_path)
532 }
533
534 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
539 let json_path = Self::settings_json_path()?;
540 let toml_path = Self::settings_toml_path()?;
541
542 let (primary, secondary) = if prefer_json {
543 (&json_path, &toml_path)
544 } else {
545 (&toml_path, &json_path)
546 };
547
548 if primary.exists() {
549 return Ok(primary.clone());
550 }
551
552 if secondary.exists() {
553 return Ok(secondary.clone());
554 }
555
556 Ok(primary.clone())
558 }
559
560 pub fn detect_format(path: &Path) -> SettingsFormat {
562 match path.extension().and_then(|e| e.to_str()) {
563 Some("json") => SettingsFormat::Json,
564 Some("toml") => SettingsFormat::Toml,
565 _ => SettingsFormat::Json, }
567 }
568
569 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
574 let mut dir = start_dir.to_path_buf();
575 loop {
576 let json_candidate = dir.join(".oxicode").join("settings.json");
578 if json_candidate.exists() {
579 return Some(json_candidate);
580 }
581
582 let toml_candidate = dir.join(".oxicode").join("settings.toml");
583 if toml_candidate.exists() {
584 return Some(toml_candidate);
585 }
586
587 if !dir.pop() {
588 return None;
589 }
590 }
591 }
592
593 pub fn effective_session_dir(&self) -> Result<PathBuf> {
597 if let Some(ref dir) = self.session_dir {
598 return Ok(dir.clone());
599 }
600 Ok(Self::settings_dir()?.join("sessions"))
601 }
602
603 pub fn load() -> Result<Self> {
621 Self::load_from_cwd()
622 }
623
624 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
630 Self::load_from_with(dir, None)
631 }
632
633 pub fn load_from_with(
652 dir: &std::path::Path,
653 global_override: Option<&std::path::Path>,
654 ) -> Result<Self> {
655 let mut settings = Settings::default();
657
658 let resolved_global: Option<std::path::PathBuf> = match global_override {
661 Some(p) => Some(p.to_path_buf()),
662 None => Self::settings_path().ok(),
663 };
664 if let Some(ref gp) = resolved_global
665 && gp.exists()
666 {
667 settings = Self::layer_file(&settings, gp)?;
668 }
669
670 if let Some(project_path) = Self::find_project_settings(dir) {
672 settings = Self::layer_file(&settings, &project_path)?;
673 }
674
675 settings.apply_env();
677
678 settings = Self::migrate(settings)?;
680
681 if settings.bash_pty {
687 tracing::warn!(
688 "settings.bash_pty = true is currently inert — the agent tool cannot see cli settings yet. To enable PTY-backed bash right now, export OXICODE_BASH_PTY=1 in your environment."
689 );
690 }
691
692 Ok(settings)
693 }
694
695 pub fn load_from_cwd() -> Result<Self> {
697 let cwd = env::current_dir().context("Cannot determine current directory")?;
698 Self::load_from(&cwd)
699 }
700
701 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
707 let content = fs::read_to_string(path)
708 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
709
710 let format = Self::detect_format(path);
711 let overlay: serde_json::Value = match format {
712 SettingsFormat::Toml => {
713 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
714 format!("Failed to parse TOML settings from {}", path.display())
715 })?;
716 toml_value_to_json(toml_value)
718 }
719 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
720 format!("Failed to parse JSON settings from {}", path.display())
721 })?,
722 };
723
724 let base_json =
728 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
729
730 let merged = merge_json_values(base_json, overlay);
731 let result: Settings =
732 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
733
734 Ok(result)
735 }
736
737 #[allow(dead_code)]
762 pub fn apply_env(&mut self) {
763 }
767
768 #[allow(dead_code)]
774 pub fn from_env() -> Self {
775 Self::default()
776 }
777
778 pub fn save(&self) -> Result<()> {
785 let dir = Self::settings_dir()?;
786 let path = Self::settings_path()?;
787
788 if !dir.exists() {
789 fs::create_dir_all(&dir).with_context(|| {
790 format!("Failed to create settings directory {}", dir.display())
791 })?;
792 }
793
794 let format = Self::detect_format(&path);
795 let content = Self::serialize_for_format(self, format)?;
796
797 let tmp_path = path.with_extension("tmp");
799 fs::write(&tmp_path, &content)
800 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
801 fs::rename(&tmp_path, &path)
802 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
803
804 Ok(())
805 }
806
807 pub fn save_to(&self, path: &Path) -> Result<()> {
809 if let Some(parent) = path.parent()
810 && !parent.exists()
811 {
812 fs::create_dir_all(parent)
813 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
814 }
815
816 let format = Self::detect_format(path);
817 let content = Self::serialize_for_format(self, format)?;
818
819 let tmp_path = path.with_extension("tmp");
821 fs::write(&tmp_path, &content)
822 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
823 fs::rename(&tmp_path, path)
824 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
825
826 Ok(())
827 }
828
829 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
833 let dir = project_dir.join(".oxicode");
834
835 if !dir.exists() {
836 fs::create_dir_all(&dir).with_context(|| {
837 format!(
838 "Failed to create project settings directory {}",
839 dir.display()
840 )
841 })?;
842 }
843
844 let json_path = dir.join("settings.json");
846 let toml_path = dir.join("settings.toml");
847
848 let path = if json_path.exists() {
849 &json_path
850 } else if toml_path.exists() {
851 &toml_path
852 } else {
853 &json_path
855 };
856
857 let format = Self::detect_format(path);
858 let content = Self::serialize_for_format(self, format)?;
859
860 let tmp_path = path.with_extension("tmp");
862 fs::write(&tmp_path, &content)
863 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
864 fs::rename(&tmp_path, path)
865 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
866
867 Ok(())
868 }
869
870 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
872 match format {
873 SettingsFormat::Toml => {
874 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
875 }
876 SettingsFormat::Json => serde_json::to_string_pretty(settings)
877 .context("Failed to serialize settings to JSON"),
878 }
879 }
880
881 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
883 match format {
884 SettingsFormat::Toml => {
885 toml::from_str(content).context("Failed to parse TOML settings")
886 }
887 SettingsFormat::Json => {
888 serde_json::from_str(content).context("Failed to parse JSON settings")
889 }
890 }
891 }
892
893 pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
902 if let Some(m) = model {
903 self.last_used_model = Some(m);
904 }
905 if let Some(p) = provider {
906 self.last_used_provider = Some(p);
907 }
908 }
909
910 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
913 cli_model.map(String::from).or_else(|| {
914 let model = self.last_used_model.as_ref()?;
919 if model.contains('/') {
920 Some(model.clone())
922 } else if let Some(ref provider) = self.last_used_provider {
923 Some(format!("{}/{}", provider, model))
925 } else {
926 Some(model.clone())
927 }
928 })
929 }
930
931 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
934 cli_provider
935 .map(String::from)
936 .or_else(|| self.last_used_provider.clone())
937 }
938
939 pub fn effective_temperature(&self) -> Option<f64> {
942 self.default_temperature
943 .or(self.temperature.map(|t| t as f64))
944 }
945
946 pub fn effective_max_tokens(&self) -> Option<usize> {
949 self.max_response_tokens
950 .or(self.max_tokens.map(|t| t as usize))
951 }
952
953 pub fn save_last_used(model_id: &str) {
959 if let Ok(mut settings) = Self::load() {
960 if let Some((provider, model)) = model_id.split_once('/') {
961 settings.last_used_provider = Some(provider.to_string());
962 settings.last_used_model = Some(model.to_string());
963 } else {
964 settings.last_used_model = Some(model_id.to_string());
965 }
966 let _ = settings.save();
967 }
968 }
969
970 pub fn save_theme(&mut self, name: &str) -> Result<()> {
972 self.theme = name.to_string();
973 self.save()
974 }
975
976 pub fn get_theme_name(&self) -> String {
978 if self.theme.is_empty() || self.theme == "default" {
979 "oxi".to_string()
980 } else {
981 self.theme.clone()
982 }
983 }
984
985 fn migrate(settings: Settings) -> Result<Settings> {
999 let mut settings = settings;
1000
1001 match settings.version {
1002 SETTINGS_VERSION => {
1003 }
1005 0 => {
1006 if settings.tool_timeout_seconds == 0 {
1009 settings.tool_timeout_seconds = default_tool_timeout();
1010 }
1011 settings.version = SETTINGS_VERSION;
1012
1013 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1014 }
1015 1 | 2 => {
1016 settings.version = SETTINGS_VERSION;
1020 tracing::info!(
1021 "Migrated settings from version {} to {}",
1022 settings.version,
1023 SETTINGS_VERSION
1024 );
1025 }
1026 3 => {
1027 if let Some(model) = settings.default_model.take() {
1030 if let Some((provider, model_name)) = model.split_once('/') {
1031 settings.last_used_provider = Some(provider.to_string());
1032 settings.last_used_model = Some(model_name.to_string());
1033 } else {
1034 settings.last_used_model = Some(model);
1035 }
1036 }
1037 settings.version = SETTINGS_VERSION;
1038 tracing::info!(
1039 "Migrated settings from version 3 to {} (default_model → last_used_model)",
1040 SETTINGS_VERSION
1041 );
1042 }
1043 4 => {
1044 settings.version = SETTINGS_VERSION;
1046 tracing::info!("Migrated settings from version 4 to {}", SETTINGS_VERSION);
1047 }
1048 5 => {
1049 settings.version = SETTINGS_VERSION;
1051 tracing::info!("Migrated settings from version 5 to {}", SETTINGS_VERSION);
1052 }
1053 6 => {
1054 settings.version = SETTINGS_VERSION;
1057 tracing::info!(
1058 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1059 SETTINGS_VERSION
1060 );
1061 }
1062 7 => {
1063 settings.version = SETTINGS_VERSION;
1066 tracing::info!(
1067 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1068 SETTINGS_VERSION
1069 );
1070 }
1071 8 => {
1072 settings.version = SETTINGS_VERSION;
1075 tracing::info!(
1076 "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1077 SETTINGS_VERSION
1078 );
1079 }
1080 v if v > SETTINGS_VERSION => {
1081 anyhow::bail!(
1083 "Settings version {} is newer than supported version {}. \
1084 Please update oxicode.",
1085 v,
1086 SETTINGS_VERSION
1087 );
1088 }
1089 v => {
1090 tracing::warn!(
1092 "Unknown settings version {}, attempting migration to {}",
1093 v,
1094 SETTINGS_VERSION
1095 );
1096 settings.version = SETTINGS_VERSION;
1097 }
1098 }
1099
1100 Ok(settings)
1101 }
1102}
1103
1104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1108pub enum SettingsFormat {
1109 #[default]
1111 Json,
1112 Toml,
1114}
1115
1116impl SettingsFormat {
1117 pub fn extension(&self) -> &'static str {
1119 match self {
1120 SettingsFormat::Json => "json",
1121 SettingsFormat::Toml => "toml",
1122 }
1123 }
1124}
1125
1126fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1130 match toml {
1131 toml::Value::String(s) => serde_json::Value::String(s),
1132 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1133 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1134 .map(serde_json::Value::Number)
1135 .unwrap_or(serde_json::Value::Null),
1136 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1137 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1138 toml::Value::Array(arr) => {
1139 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1140 }
1141 toml::Value::Table(table) => {
1142 let obj = table
1143 .into_iter()
1144 .map(|(k, v)| (k, toml_value_to_json(v)))
1145 .collect();
1146 serde_json::Value::Object(obj)
1147 }
1148 }
1149}
1150
1151fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1153 match (base, override_) {
1154 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1156 let mut result = base_map;
1157 for (key, override_value) in override_map {
1158 let base_value = result.remove(&key);
1159 let merged = match base_value {
1160 Some(base_v) => merge_json_values(base_v, override_value),
1161 None => override_value,
1162 };
1163 result.insert(key, merged);
1164 }
1165 serde_json::Value::Object(result)
1166 }
1167 (_, override_) => override_,
1169 }
1170}
1171
1172pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1174 match s.to_lowercase().as_str() {
1175 "off" | "none" => Some(ThinkingLevel::Off),
1176 "minimal" => Some(ThinkingLevel::Minimal),
1177 "low" => Some(ThinkingLevel::Low),
1178 "medium" | "standard" => Some(ThinkingLevel::Medium),
1179 "high" | "thorough" => Some(ThinkingLevel::High),
1180 "xhigh" => Some(ThinkingLevel::XHigh),
1181 _ => None,
1182 }
1183}
1184
1185#[allow(dead_code)]
1187fn parse_boolish(s: &str) -> Result<bool> {
1188 match s.to_lowercase().as_str() {
1189 "true" | "1" | "yes" | "on" => Ok(true),
1190 "false" | "0" | "no" | "off" => Ok(false),
1191 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1192 }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 #[test]
1200 fn inline_images_defaults_true_and_reads_override() {
1201 use super::*;
1202 assert!(Settings::default().inline_images, "previews on by default");
1203 let s: Settings = toml::from_str("inline_images = false").unwrap();
1204 assert!(!s.inline_images, "settings file can disable previews");
1205 }
1206
1207 use super::*;
1208 use std::io::Write as IoWrite;
1209 use std::sync::Mutex;
1210
1211 #[test]
1212 fn todo_settings_default_preserve_current_behavior() {
1213 let s = Settings::default();
1214 assert_eq!(s.todo_eager_mode, TodoEagerMode::Off);
1215 assert!(s.todo_reminders_enabled);
1216 assert_eq!(s.todo_reminders_max, 3);
1217 assert_eq!(s.todo_clear_delay_secs, 60);
1218 }
1219
1220 #[test]
1221 fn todo_eager_mode_round_trips_through_toml() {
1222 let parsed: TodoEagerMode = toml::from_str("v = \"always\"")
1223 .map(|t: toml::Value| TodoEagerMode::deserialize(t["v"].clone()).unwrap())
1224 .unwrap();
1225 assert_eq!(parsed, TodoEagerMode::Always);
1226 }
1227
1228 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1231
1232 struct EnvGuard {
1235 saved: Vec<(String, Option<String>)>,
1236 }
1237
1238 impl EnvGuard {
1239 fn new(vars: &[&str]) -> Self {
1240 let saved = vars
1241 .iter()
1242 .map(|&name| {
1243 let old = env::var(name).ok();
1244 unsafe { env::remove_var(name) };
1246 (name.to_string(), old)
1247 })
1248 .collect();
1249 Self { saved }
1250 }
1251 }
1252
1253 impl Drop for EnvGuard {
1254 fn drop(&mut self) {
1255 for (name, old) in self.saved.drain(..) {
1256 match old {
1257 Some(val) => unsafe { env::set_var(&name, val) },
1259 None => unsafe { env::remove_var(&name) },
1260 }
1261 }
1262 }
1263 }
1264
1265 #[test]
1268 fn test_default_settings() {
1269 let settings = Settings::default();
1270 assert_eq!(settings.version, SETTINGS_VERSION);
1271 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1272 assert_eq!(settings.theme, "default");
1273 assert!(settings.last_used_model.is_none());
1274 assert!(settings.last_used_provider.is_none());
1275 assert!(settings.extensions_enabled);
1276 assert!(settings.auto_compaction);
1277 assert_eq!(settings.tool_timeout_seconds, 120);
1278 }
1279
1280 #[test]
1281 fn test_merge_cli() {
1282 let mut settings = Settings::default();
1283 settings.last_used_model = Some("gpt-4o".to_string());
1284
1285 settings.merge_cli(Some("claude".to_string()), None);
1286 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1287
1288 settings.merge_cli(None, Some("google".to_string()));
1289 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1290 }
1291
1292 #[test]
1295 fn test_layer_file_overrides() {
1296 let base = Settings::default();
1297
1298 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1299 let toml_content = r#"
1300last_used_model = "openai/gpt-4o"
1301theme = "dracula"
1302"#;
1303 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1304
1305 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1306 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1307 assert_eq!(merged.theme, "dracula");
1308 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1310 assert!(merged.extensions_enabled);
1311 }
1312
1313 #[test]
1314 fn test_layer_file_preserves_unset() {
1315 let mut base = Settings::default();
1316 base.last_used_provider = Some("deepseek".to_string());
1317
1318 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1319 let toml_content = "theme = \"monokai\"\n";
1321 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1322
1323 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1324 assert_eq!(merged.theme, "monokai");
1325 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1326 }
1327
1328 #[test]
1329 fn test_load_from_dir_with_project_config() {
1330 let _guard = EnvGuard::new(&[
1331 "OXICODE_MODEL",
1332 "OXICODE_PROVIDER",
1333 "OXICODE_THEME",
1334 "OXICODE_TOOL_TIMEOUT",
1335 "OXICODE_TEMPERATURE",
1336 "OXICODE_MAX_TOKENS",
1337 "OXICODE_SESSION_DIR",
1338 "OXICODE_EXTENSIONS_ENABLED",
1339 ]);
1340 let tmp = tempfile::tempdir().unwrap();
1341 let oxicode_dir = tmp.path().join(".oxicode");
1342 fs::create_dir_all(&oxicode_dir).unwrap();
1343 let settings_path = oxicode_dir.join("settings.toml");
1344 fs::write(
1346 &settings_path,
1347 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1348 )
1349 .unwrap();
1350
1351 let settings = Settings::load_from(tmp.path()).unwrap();
1352 assert_eq!(
1354 settings.last_used_model,
1355 Some("gemini-2.0-flash".to_string())
1356 );
1357 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1358 }
1359
1360 #[test]
1361 fn test_load_from_dir_no_config() {
1362 let _guard = EnvGuard::new(&[
1364 "OXICODE_MODEL",
1365 "OXICODE_PROVIDER",
1366 "OXICODE_THEME",
1367 "OXICODE_TOOL_TIMEOUT",
1368 "OXICODE_TEMPERATURE",
1369 "OXICODE_MAX_TOKENS",
1370 "OXICODE_SESSION_DIR",
1371 "OXICODE_EXTENSIONS_ENABLED",
1372 ]);
1373 let tmp = tempfile::tempdir().unwrap();
1374 let global = tmp.path().join("nonexistent-settings.json");
1379 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1380 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1381 }
1382 #[test]
1383 fn test_from_env() {
1384 let _guard = EnvGuard::new(&[
1387 "OXICODE_MODEL",
1389 "OXICODE_THEME",
1390 "OXICODE_TOOL_TIMEOUT",
1391 "OXICODE_PROVIDER",
1392 "OXICODE_DEFAULT_MODEL",
1393 ]);
1394
1395 let settings = Settings::from_env();
1396 assert_eq!(settings.last_used_model, None);
1398 assert_eq!(settings.theme, "default");
1399 assert_eq!(settings.tool_timeout_seconds, 120);
1400 }
1401
1402 #[test]
1403 fn test_apply_env_boolish() {
1404 let _guard = EnvGuard::new(&["OXICODE_EXTENSIONS_ENABLED"]);
1407 unsafe { env::set_var("OXICODE_EXTENSIONS_ENABLED", "0") };
1408
1409 let mut settings = Settings::default();
1410 settings.apply_env();
1411 assert!(settings.extensions_enabled); }
1414
1415 #[test]
1416 fn test_apply_env_temperature() {
1417 let _guard = EnvGuard::new(&["OXICODE_TEMPERATURE"]);
1419 unsafe { env::set_var("OXICODE_TEMPERATURE", "0.7") };
1420
1421 let mut settings = Settings::default();
1422 settings.apply_env();
1423 assert_eq!(settings.default_temperature, None);
1425 }
1426
1427 #[test]
1428 fn test_env_does_not_override_when_unset() {
1429 let _guard = EnvGuard::new(&[
1430 "OXICODE_MODEL",
1431 "OXICODE_PROVIDER",
1432 "OXICODE_THEME",
1433 "OXICODE_TEMPERATURE",
1434 ]);
1435 let settings = Settings::from_env();
1436 assert!(settings.last_used_model.is_none());
1437 assert!(settings.last_used_provider.is_none());
1438 }
1439
1440 #[test]
1441 fn test_parse_thinking_level() {
1442 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1443 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1444 assert_eq!(
1445 parse_thinking_level("MINIMAL"),
1446 Some(ThinkingLevel::Minimal)
1447 );
1448 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1449 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1450 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1451 assert_eq!(
1452 parse_thinking_level("Standard"),
1453 Some(ThinkingLevel::Medium)
1454 );
1455 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1456 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1457 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1458 assert_eq!(parse_thinking_level("invalid"), None);
1459 }
1460
1461 #[test]
1462 fn test_parse_boolish() {
1463 assert!(parse_boolish("true").unwrap());
1464 assert!(parse_boolish("1").unwrap());
1465 assert!(parse_boolish("yes").unwrap());
1466 assert!(parse_boolish("ON").unwrap());
1467 assert!(!parse_boolish("false").unwrap());
1468 assert!(!parse_boolish("0").unwrap());
1469 assert!(!parse_boolish("no").unwrap());
1470 assert!(!parse_boolish("OFF").unwrap());
1471 assert!(parse_boolish("maybe").is_err());
1472 }
1473
1474 #[test]
1477 fn test_effective_model_returns_last_used() {
1478 let mut settings = Settings::default();
1479 settings.last_used_model = Some("openai/gpt-4o".to_string());
1480 assert_eq!(
1481 settings.effective_model(None),
1482 Some("openai/gpt-4o".to_string())
1483 );
1484 }
1485
1486 #[test]
1487 fn test_effective_model_cli_overrides() {
1488 let mut settings = Settings::default();
1489 settings.last_used_model = Some("openai/gpt-4o".to_string());
1490 assert_eq!(
1491 settings.effective_model(Some("anthropic/claude-3")),
1492 Some("anthropic/claude-3".to_string())
1493 );
1494 }
1495
1496 #[test]
1497 fn test_effective_model_none_when_unset() {
1498 let settings = Settings::default();
1499 assert_eq!(settings.effective_model(None), None);
1500 }
1501
1502 #[test]
1503 fn test_effective_model_falls_back_to_last_used() {
1504 let mut settings = Settings::default();
1505 settings.last_used_model = Some("anthropic/claude-3".to_string());
1506 assert_eq!(
1507 settings.effective_model(None),
1508 Some("anthropic/claude-3".to_string())
1509 );
1510 }
1511
1512 #[test]
1513 fn test_effective_model_returns_none_when_nothing_set() {
1514 let settings = Settings::default();
1515 assert_eq!(settings.effective_model(None), None);
1516 }
1517
1518 #[test]
1519 fn test_effective_temperature_prefers_f64() {
1520 let mut settings = Settings::default();
1521 settings.temperature = Some(0.5);
1522 settings.default_temperature = Some(0.7);
1523 assert_eq!(settings.effective_temperature(), Some(0.7));
1524 }
1525
1526 #[test]
1527 fn test_effective_temperature_falls_back_to_f32() {
1528 let mut settings = Settings::default();
1529 settings.temperature = Some(0.5);
1530 assert_eq!(settings.effective_temperature(), Some(0.5));
1531 }
1532
1533 #[test]
1534 fn test_effective_max_tokens_prefers_usize() {
1535 let mut settings = Settings::default();
1536 settings.max_tokens = Some(1024);
1537 settings.max_response_tokens = Some(4096);
1538 assert_eq!(settings.effective_max_tokens(), Some(4096));
1539 }
1540
1541 #[test]
1542 fn test_effective_max_tokens_falls_back_to_u32() {
1543 let mut settings = Settings::default();
1544 settings.max_tokens = Some(1024);
1545 assert_eq!(settings.effective_max_tokens(), Some(1024));
1546 }
1547
1548 #[test]
1551 fn test_effective_session_dir_default() {
1552 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1553 let settings = Settings::default();
1554 let dir = settings.effective_session_dir().unwrap();
1555 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1556 }
1557
1558 #[test]
1559 fn test_effective_session_dir_from_field() {
1560 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1561 let mut settings = Settings::default();
1562 settings.session_dir = Some(PathBuf::from("/tmp/oxicode-sessions"));
1563 assert_eq!(
1564 settings.effective_session_dir().unwrap(),
1565 PathBuf::from("/tmp/oxicode-sessions")
1566 );
1567 }
1568
1569 #[test]
1570 fn test_effective_session_dir_env_disabled() {
1571 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1574 unsafe { env::set_var("OXICODE_SESSION_DIR", "/tmp/env-sessions") };
1575 let settings = Settings::default();
1576 let dir = settings.effective_session_dir().unwrap();
1578 assert!(
1579 dir.ends_with("sessions"),
1580 "expected default sessions dir, got: {:?}",
1581 dir
1582 );
1583 }
1584
1585 #[test]
1588 fn test_migration_v0_to_v1() {
1589 let mut settings = Settings::default();
1590 settings.version = 0;
1591 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1594 assert_eq!(migrated.version, SETTINGS_VERSION);
1595 assert_eq!(migrated.tool_timeout_seconds, 120);
1596 }
1597
1598 #[test]
1599 fn test_migration_already_current() {
1600 let settings = Settings::default();
1601 let migrated = Settings::migrate(settings).unwrap();
1602 assert_eq!(migrated.version, SETTINGS_VERSION);
1603 }
1604
1605 #[test]
1606 fn test_migration_v3_to_v4_splits_model() {
1607 let mut settings = Settings::default();
1608 settings.version = 3;
1609 settings.default_model = Some("openai/gpt-4o".to_string());
1610 settings.default_provider = None;
1611
1612 let migrated = Settings::migrate(settings).unwrap();
1613 assert_eq!(migrated.version, SETTINGS_VERSION);
1614 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1615 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1616 }
1617
1618 #[test]
1619 fn test_migration_v3_no_slash_keeps_model() {
1620 let mut settings = Settings::default();
1621 settings.version = 3;
1622 settings.default_model = Some("bare-model-name".to_string());
1623
1624 let migrated = Settings::migrate(settings).unwrap();
1625 assert_eq!(migrated.version, SETTINGS_VERSION);
1626 assert_eq!(
1627 migrated.last_used_model,
1628 Some("bare-model-name".to_string())
1629 );
1630 }
1631
1632 #[test]
1633 fn test_migration_future_version_fails() {
1634 let mut settings = Settings::default();
1635 settings.version = 9999;
1636 assert!(Settings::migrate(settings).is_err());
1637 }
1638
1639 #[test]
1640 fn test_default_glyph_set_is_unicode() {
1641 let settings = Settings::default();
1642 assert_eq!(
1643 settings.glyph_set,
1644 GlyphSet::Unicode,
1645 "glyph_set must default to Unicode"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1651 let mut settings = Settings::default();
1654 settings.version = 7;
1655 settings.glyph_set = GlyphSet::default();
1657
1658 let migrated = Settings::migrate(settings).unwrap();
1659 assert_eq!(migrated.version, SETTINGS_VERSION);
1660 assert_eq!(
1661 migrated.glyph_set,
1662 GlyphSet::Unicode,
1663 "v7 → v8 migration must default glyph_set to unicode"
1664 );
1665 }
1666
1667 #[test]
1668 fn test_glyph_set_persists_through_roundtrip() {
1669 let mut original = Settings::default();
1673 original.glyph_set = GlyphSet::Nerd;
1674 let content = toml::to_string_pretty(&original).unwrap();
1675 assert!(
1676 content.contains("glyph_set = \"nerd\""),
1677 "nerd preset must serialize to snake_case; got:\n{content}"
1678 );
1679 let loaded: Settings = toml::from_str(&content).unwrap();
1680 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1681 original.glyph_set = GlyphSet::Unicode;
1683 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1684 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1685 }
1686
1687 #[test]
1688 fn test_save_and_load_roundtrip() {
1689 let tmp = tempfile::tempdir().unwrap();
1690 let settings_path = tmp.path().join("settings.toml");
1691
1692 let mut original = Settings::default();
1693 original.last_used_model = Some("gpt-4o".to_string());
1694 original.last_used_provider = Some("openai".to_string());
1695 original.theme = "dracula".to_string();
1696 original.tool_timeout_seconds = 60;
1697
1698 let content = toml::to_string_pretty(&original).unwrap();
1700 fs::write(&settings_path, &content).unwrap();
1701
1702 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1704 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1705
1706 assert_eq!(loaded.last_used_model, original.last_used_model);
1707 assert_eq!(loaded.theme, original.theme);
1708 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1709 }
1710
1711 #[test]
1712 fn test_toml_roundtrip_preserves_new_fields() {
1713 let mut settings = Settings::default();
1714 settings.default_temperature = Some(0.8);
1715 settings.max_response_tokens = Some(8192);
1716 settings.auto_compaction = false;
1717 settings.extensions_enabled = false;
1718 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1719
1720 let toml_str = toml::to_string_pretty(&settings).unwrap();
1721 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1722
1723 assert_eq!(parsed.default_temperature, Some(0.8));
1724 assert_eq!(parsed.max_response_tokens, Some(8192));
1725 assert!(!parsed.auto_compaction);
1726 assert!(!parsed.extensions_enabled);
1727 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1728 }
1729
1730 #[test]
1733 fn test_json_roundtrip() {
1734 let mut settings = Settings::default();
1735 settings.last_used_model = Some("gpt-4o".to_string());
1736 settings.last_used_provider = Some("openai".to_string());
1737 settings.theme = "dracula".to_string();
1738 settings.tool_timeout_seconds = 60;
1739 settings.default_temperature = Some(0.8);
1740 settings.max_response_tokens = Some(8192);
1741
1742 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1743 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1744
1745 assert_eq!(parsed.last_used_model, settings.last_used_model);
1746 assert_eq!(parsed.theme, settings.theme);
1747 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1748 assert_eq!(parsed.default_temperature, settings.default_temperature);
1749 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1750 }
1751
1752 #[test]
1753 fn test_json_serialize_for_format() {
1754 let mut settings = Settings::default();
1755 settings.last_used_model = Some("claude-3".to_string());
1756 settings.last_used_provider = Some("anthropic".to_string());
1757 settings.thinking_level = ThinkingLevel::Minimal;
1758
1759 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1760 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1761
1762 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1763 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1764 }
1765
1766 #[test]
1767 fn test_toml_serialize_for_format() {
1768 let mut settings = Settings::default();
1769 settings.last_used_model = Some("gemini-pro".to_string());
1770 settings.last_used_provider = Some("google".to_string());
1771 settings.thinking_level = ThinkingLevel::High;
1772
1773 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1774 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1775
1776 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1777 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1778 }
1779
1780 #[test]
1781 fn test_parse_from_str_json() {
1782 let json_content = r#"{
1783 "last_used_model": "gpt-4",
1784 "last_used_provider": "openai",
1785 "theme": "nord",
1786 "tool_timeout_seconds": 90
1787 }"#;
1788
1789 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1790 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1791 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1792 assert_eq!(settings.theme, "nord");
1793 assert_eq!(settings.tool_timeout_seconds, 90);
1794 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1796 assert!(settings.extensions_enabled);
1797 }
1798
1799 #[test]
1800 fn test_parse_from_str_toml() {
1801 let toml_content = r#"
1802last_used_model = "claude-opus"
1803last_used_provider = "anthropic"
1804theme = "monokai"
1805tool_timeout_seconds = 45
1806"#;
1807
1808 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1809 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1810 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1811 assert_eq!(settings.theme, "monokai");
1812 assert_eq!(settings.tool_timeout_seconds, 45);
1813 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1814 }
1815
1816 #[test]
1817 fn test_layer_file_json() {
1818 let base = Settings::default();
1819
1820 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1821 let json_content = r#"{
1822 "last_used_model": "gpt-4o",
1823 "last_used_provider": "openai",
1824 "theme": "dracula",
1825 "auto_compaction": false
1826 }"#;
1827 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1828
1829 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1830 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1831 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1832 assert_eq!(merged.theme, "dracula");
1833 assert!(!merged.auto_compaction);
1834 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1836 assert!(merged.extensions_enabled);
1837 assert_eq!(merged.tool_timeout_seconds, 120);
1838 }
1839
1840 #[test]
1841 fn test_layer_file_json_preserves_unset() {
1842 let mut base = Settings::default();
1843 base.last_used_provider = Some("deepseek".to_string());
1844
1845 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1846 let json_content = r#"{ "theme": "nord" }"#;
1847 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1848
1849 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1850 assert_eq!(merged.theme, "nord");
1851 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1852 }
1853
1854 #[test]
1855 fn test_save_to_json() {
1856 let tmp = tempfile::tempdir().unwrap();
1857 let settings_path = tmp.path().join("settings.json");
1858
1859 let mut settings = Settings::default();
1860 settings.last_used_model = Some("gpt-4o".to_string());
1861 settings.last_used_provider = Some("openai".to_string());
1862 settings.theme = "dracula".to_string();
1863 settings.tool_timeout_seconds = 60;
1864
1865 settings.save_to(&settings_path).unwrap();
1866
1867 let content = fs::read_to_string(&settings_path).unwrap();
1869 let parsed: Settings = serde_json::from_str(&content).unwrap();
1870 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1871 assert_eq!(parsed.theme, "dracula");
1872 assert_eq!(parsed.tool_timeout_seconds, 60);
1873 }
1874
1875 #[test]
1876 fn test_save_to_toml() {
1877 let tmp = tempfile::tempdir().unwrap();
1878 let settings_path = tmp.path().join("settings.toml");
1879
1880 let mut settings = Settings::default();
1881 settings.last_used_model = Some("gemini-pro".to_string());
1882 settings.last_used_provider = Some("google".to_string());
1883 settings.theme = "monokai".to_string();
1884 settings.tool_timeout_seconds = 90;
1885
1886 settings.save_to(&settings_path).unwrap();
1887
1888 let content = fs::read_to_string(&settings_path).unwrap();
1890 let parsed: Settings = toml::from_str(&content).unwrap();
1891 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1892 assert_eq!(parsed.theme, "monokai");
1893 assert_eq!(parsed.tool_timeout_seconds, 90);
1894 }
1895
1896 #[test]
1897 fn test_load_from_dir_with_json_project_config() {
1898 let _guard = EnvGuard::new(&[
1899 "OXICODE_MODEL",
1900 "OXICODE_PROVIDER",
1901 "OXICODE_THEME",
1902 "OXICODE_TOOL_TIMEOUT",
1903 "OXICODE_TEMPERATURE",
1904 "OXICODE_MAX_TOKENS",
1905 "OXICODE_SESSION_DIR",
1906 "OXICODE_EXTENSIONS_ENABLED",
1907 ]);
1908 let tmp = tempfile::tempdir().unwrap();
1909 let oxicode_dir = tmp.path().join(".oxicode");
1910 fs::create_dir_all(&oxicode_dir).unwrap();
1911 let settings_path = oxicode_dir.join("settings.json");
1912 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1914 fs::write(&settings_path, json_content).unwrap();
1915
1916 let settings = Settings::load_from(tmp.path()).unwrap();
1917 assert_eq!(
1919 settings.last_used_model,
1920 Some("gemini-2.0-flash".to_string())
1921 );
1922 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1923 }
1924
1925 #[test]
1926 fn test_find_project_settings_json_priority() {
1927 let tmp = tempfile::tempdir().unwrap();
1928 let oxicode_dir = tmp.path().join(".oxicode");
1929 fs::create_dir_all(&oxicode_dir).unwrap();
1930
1931 let json_path = oxicode_dir.join("settings.json");
1933 let toml_path = oxicode_dir.join("settings.toml");
1934 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1935 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1936
1937 let found = Settings::find_project_settings(tmp.path());
1939 assert!(found.is_some());
1940 assert_eq!(
1941 found.unwrap().file_name().unwrap().to_str().unwrap(),
1942 "settings.json"
1943 );
1944 }
1945
1946 #[test]
1947 fn test_find_project_settings_json_only() {
1948 let tmp = tempfile::tempdir().unwrap();
1949 let oxicode_dir = tmp.path().join(".oxicode");
1950 fs::create_dir_all(&oxicode_dir).unwrap();
1951
1952 let json_path = oxicode_dir.join("settings.json");
1953 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1954
1955 let found = Settings::find_project_settings(tmp.path());
1956 assert!(found.is_some());
1957 assert_eq!(
1958 found.unwrap().file_name().unwrap().to_str().unwrap(),
1959 "settings.json"
1960 );
1961 }
1962
1963 #[test]
1964 fn test_find_project_settings_toml_fallback() {
1965 let tmp = tempfile::tempdir().unwrap();
1966 let oxicode_dir = tmp.path().join(".oxicode");
1967 fs::create_dir_all(&oxicode_dir).unwrap();
1968
1969 let toml_path = oxicode_dir.join("settings.toml");
1970 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1971
1972 let found = Settings::find_project_settings(tmp.path());
1973 assert!(found.is_some());
1974 assert_eq!(
1975 found.unwrap().file_name().unwrap().to_str().unwrap(),
1976 "settings.toml"
1977 );
1978 }
1979
1980 #[test]
1981 fn test_detect_format() {
1982 let json_path = PathBuf::from("/test/settings.json");
1983 let toml_path = PathBuf::from("/test/settings.toml");
1984 let unknown_path = PathBuf::from("/test/settings");
1985
1986 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1987 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1988 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1989 }
1991
1992 #[test]
1993 fn test_settings_format_extension() {
1994 assert_eq!(SettingsFormat::Json.extension(), "json");
1995 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1996 }
1997
1998 #[test]
1999 fn test_layer_json_over_toml() {
2000 let tmp = tempfile::tempdir().unwrap();
2002 let oxicode_dir = tmp.path().join(".oxicode");
2003 fs::create_dir_all(&oxicode_dir).unwrap();
2004
2005 let json_path = oxicode_dir.join("settings.json");
2006 let toml_path = oxicode_dir.join("settings.toml");
2007
2008 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2010 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2012
2013 let settings = Settings::load_from(tmp.path()).unwrap();
2015 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2016 }
2017
2018 #[test]
2019 fn test_mixed_format_loading() {
2020 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2022 let toml_content = r#"
2023last_used_model = "loaded-via-toml"
2024theme = "loaded-theme"
2025"#;
2026 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2027
2028 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2029 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2030 assert_eq!(merged.theme, "loaded-theme");
2031 }
2032
2033 #[test]
2034 fn test_merge_json_values() {
2035 let base = serde_json::json!({
2036 "version": 1,
2037 "theme": "default",
2038 "extensions": ["ext1"],
2039 "nested": {
2040 "a": 1,
2041 "b": 2
2042 }
2043 });
2044
2045 let override_ = serde_json::json!({
2046 "version": 2,
2047 "theme": "dark",
2048 "extensions": ["ext2"],
2049 "nested": {
2050 "b": 20,
2051 "c": 30
2052 }
2053 });
2054
2055 let merged = merge_json_values(base, override_);
2056
2057 assert_eq!(merged["version"], 2);
2058 assert_eq!(merged["theme"], "dark");
2059 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2061 assert_eq!(merged["nested"]["a"], 1);
2063 assert_eq!(merged["nested"]["b"], 20);
2064 assert_eq!(merged["nested"]["c"], 30);
2065 }
2066
2067 #[test]
2068 fn test_save_project_preserves_existing_format() {
2069 let tmp = tempfile::tempdir().unwrap();
2070 let oxicode_dir = tmp.path().join(".oxicode");
2071 fs::create_dir_all(&oxicode_dir).unwrap();
2072
2073 let toml_path = oxicode_dir.join("settings.toml");
2075 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2076
2077 let mut settings = Settings::default();
2078 settings.theme = "new-theme".to_string();
2079 settings.save_project(tmp.path()).unwrap();
2080
2081 let content = fs::read_to_string(&toml_path).unwrap();
2083 assert!(content.contains("new-theme"));
2084 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2085 }
2086
2087 #[test]
2088 fn test_save_project_creates_json_by_default() {
2089 let tmp = tempfile::tempdir().unwrap();
2090 let oxicode_dir = tmp.path().join(".oxicode");
2091 fs::create_dir_all(&oxicode_dir).unwrap();
2092 let mut settings = Settings::default();
2095 settings.theme = "json-theme".to_string();
2096 settings.save_project(tmp.path()).unwrap();
2097
2098 let json_path = oxicode_dir.join("settings.json");
2100 assert!(json_path.exists());
2101 let content = fs::read_to_string(&json_path).unwrap();
2102 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2103 assert!(content.contains("json-theme"));
2104 }
2105
2106 #[test]
2109 fn test_custom_provider_default_api() {
2110 use super::CustomProvider;
2111 let cp = CustomProvider {
2112 name: "test".to_string(),
2113 base_url: "https://api.test.com/v1".to_string(),
2114 api_key_env: "TEST_API_KEY".to_string(),
2115 api: super::default_custom_provider_api(),
2116 };
2117 assert_eq!(cp.api, "openai-completions");
2118 }
2119
2120 #[test]
2121 fn test_custom_provider_toml_deserialize() {
2122 let toml_content = r#"
2123[[custom_providers]]
2124name = "minimax"
2125base_url = "https://api.minimax.chat/v1"
2126api_key_env = "MINIMAX_API_KEY"
2127api = "openai-completions"
2128
2129[[custom_providers]]
2130name = "zai"
2131base_url = "https://api.z.ai/v1"
2132api_key_env = "ZAI_API_KEY"
2133api = "openai-responses"
2134"#;
2135 let settings: Settings = toml::from_str(toml_content).unwrap();
2136 assert_eq!(settings.custom_providers.len(), 2);
2137 assert_eq!(settings.custom_providers[0].name, "minimax");
2138 assert_eq!(
2139 settings.custom_providers[0].base_url,
2140 "https://api.minimax.chat/v1"
2141 );
2142 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2143 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2144 assert_eq!(settings.custom_providers[1].name, "zai");
2145 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2146 }
2147
2148 #[test]
2149 fn test_custom_provider_json_deserialize() {
2150 let json_content = r#"{
2151 "custom_providers": [
2152 {
2153 "name": "minimax",
2154 "base_url": "https://api.minimax.chat/v1",
2155 "api_key_env": "MINIMAX_API_KEY",
2156 "api": "openai-completions"
2157 }
2158 ]
2159 }"#;
2160 let settings: Settings = serde_json::from_str(json_content).unwrap();
2161 assert_eq!(settings.custom_providers.len(), 1);
2162 assert_eq!(settings.custom_providers[0].name, "minimax");
2163 }
2164
2165 #[test]
2166 fn test_custom_provider_toml_roundtrip() {
2167 let mut settings = Settings::default();
2168 settings.custom_providers.push(super::CustomProvider {
2169 name: "test".to_string(),
2170 base_url: "https://api.test.com/v1".to_string(),
2171 api_key_env: "TEST_API_KEY".to_string(),
2172 api: "openai-completions".to_string(),
2173 });
2174
2175 let toml_str = toml::to_string_pretty(&settings).unwrap();
2176 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2177 assert_eq!(parsed.custom_providers.len(), 1);
2178 assert_eq!(parsed.custom_providers[0].name, "test");
2179 assert_eq!(
2180 parsed.custom_providers[0].base_url,
2181 "https://api.test.com/v1"
2182 );
2183 }
2184
2185 #[test]
2186 fn test_custom_provider_defaults_empty() {
2187 let settings = Settings::default();
2188 assert!(settings.custom_providers.is_empty());
2189 }
2190
2191 #[test]
2192 fn test_custom_provider_layer_file() {
2193 let base = Settings::default();
2194
2195 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2196 let toml_content = r#"
2197[[custom_providers]]
2198name = "my-provider"
2199base_url = "https://api.my-provider.com/v1"
2200api_key_env = "MY_PROVIDER_API_KEY"
2201"#;
2202 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2203
2204 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2205 assert_eq!(merged.custom_providers.len(), 1);
2206 assert_eq!(merged.custom_providers[0].name, "my-provider");
2207 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2209 }
2210
2211 #[test]
2212 fn settings_deserialise_hooks_array() {
2213 let toml = r#"
2214 [[hooks]]
2215 event = "PreToolUse"
2216 matcher = "bash|write"
2217 command = "echo pre"
2218 timeout_secs = 10
2219 "#;
2220 let s: Settings = toml::from_str(toml).unwrap();
2221 assert_eq!(s.hooks.len(), 1);
2222 assert_eq!(s.hooks[0].event, oxicode_sdk::ports::HookEvent::PreToolUse);
2223 assert_eq!(s.hooks[0].matcher.as_deref(), Some("bash|write"));
2224 assert_eq!(s.hooks[0].command, "echo pre");
2225 assert_eq!(s.hooks[0].timeout_secs, Some(10));
2226 }
2227
2228 #[test]
2229 fn settings_default_has_no_hooks() {
2230 let s = Settings::default();
2231 assert!(s.hooks.is_empty());
2232 }
2233}