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 let base = dirs::home_dir().context("Cannot determine home directory")?;
494 Ok(base.join(".oxicode"))
495 }
496
497 pub fn settings_toml_path() -> Result<PathBuf> {
499 Ok(Self::settings_dir()?.join("settings.toml"))
500 }
501
502 pub fn settings_json_path() -> Result<PathBuf> {
504 Ok(Self::settings_dir()?.join("settings.json"))
505 }
506
507 pub fn settings_path() -> Result<PathBuf> {
514 let json_path = Self::settings_json_path()?;
515 let toml_path = Self::settings_toml_path()?;
516
517 if json_path.exists() && toml_path.exists() {
518 tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
520 return Ok(json_path);
521 }
522
523 if json_path.exists() {
524 return Ok(json_path);
525 }
526
527 if toml_path.exists() {
528 return Ok(toml_path);
529 }
530
531 Ok(json_path)
533 }
534
535 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
540 let json_path = Self::settings_json_path()?;
541 let toml_path = Self::settings_toml_path()?;
542
543 let (primary, secondary) = if prefer_json {
544 (&json_path, &toml_path)
545 } else {
546 (&toml_path, &json_path)
547 };
548
549 if primary.exists() {
550 return Ok(primary.clone());
551 }
552
553 if secondary.exists() {
554 return Ok(secondary.clone());
555 }
556
557 Ok(primary.clone())
559 }
560
561 pub fn detect_format(path: &Path) -> SettingsFormat {
563 match path.extension().and_then(|e| e.to_str()) {
564 Some("json") => SettingsFormat::Json,
565 Some("toml") => SettingsFormat::Toml,
566 _ => SettingsFormat::Json, }
568 }
569
570 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
575 let mut dir = start_dir.to_path_buf();
576 loop {
577 let json_candidate = dir.join(".oxicode").join("settings.json");
579 if json_candidate.exists() {
580 return Some(json_candidate);
581 }
582
583 let toml_candidate = dir.join(".oxicode").join("settings.toml");
584 if toml_candidate.exists() {
585 return Some(toml_candidate);
586 }
587
588 if !dir.pop() {
589 return None;
590 }
591 }
592 }
593
594 pub fn effective_session_dir(&self) -> Result<PathBuf> {
598 if let Some(ref dir) = self.session_dir {
599 return Ok(dir.clone());
600 }
601 Ok(Self::settings_dir()?.join("sessions"))
602 }
603
604 pub fn load() -> Result<Self> {
622 Self::load_from_cwd()
623 }
624
625 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
631 Self::load_from_with(dir, None)
632 }
633
634 pub fn load_from_with(
653 dir: &std::path::Path,
654 global_override: Option<&std::path::Path>,
655 ) -> Result<Self> {
656 let mut settings = Settings::default();
658
659 let resolved_global: Option<std::path::PathBuf> = match global_override {
662 Some(p) => Some(p.to_path_buf()),
663 None => Self::settings_path().ok(),
664 };
665 if let Some(ref gp) = resolved_global
666 && gp.exists()
667 {
668 settings = Self::layer_file(&settings, gp)?;
669 }
670
671 if let Some(project_path) = Self::find_project_settings(dir) {
673 settings = Self::layer_file(&settings, &project_path)?;
674 }
675
676 settings.apply_env();
678
679 settings = Self::migrate(settings)?;
681
682 if settings.bash_pty {
688 tracing::warn!(
689 "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."
690 );
691 }
692
693 Ok(settings)
694 }
695
696 pub fn load_from_cwd() -> Result<Self> {
698 let cwd = env::current_dir().context("Cannot determine current directory")?;
699 Self::load_from(&cwd)
700 }
701
702 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
708 let content = fs::read_to_string(path)
709 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
710
711 let format = Self::detect_format(path);
712 let overlay: serde_json::Value = match format {
713 SettingsFormat::Toml => {
714 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
715 format!("Failed to parse TOML settings from {}", path.display())
716 })?;
717 toml_value_to_json(toml_value)
719 }
720 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
721 format!("Failed to parse JSON settings from {}", path.display())
722 })?,
723 };
724
725 let base_json =
729 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
730
731 let merged = merge_json_values(base_json, overlay);
732 let result: Settings =
733 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
734
735 Ok(result)
736 }
737
738 #[allow(dead_code)]
763 pub fn apply_env(&mut self) {
764 }
768
769 #[allow(dead_code)]
775 pub fn from_env() -> Self {
776 Self::default()
777 }
778
779 pub fn save(&self) -> Result<()> {
786 let dir = Self::settings_dir()?;
787 let path = Self::settings_path()?;
788
789 if !dir.exists() {
790 fs::create_dir_all(&dir).with_context(|| {
791 format!("Failed to create settings directory {}", dir.display())
792 })?;
793 }
794
795 let format = Self::detect_format(&path);
796 let content = Self::serialize_for_format(self, format)?;
797
798 let tmp_path = path.with_extension("tmp");
800 fs::write(&tmp_path, &content)
801 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
802 fs::rename(&tmp_path, &path)
803 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
804
805 Ok(())
806 }
807
808 pub fn save_to(&self, path: &Path) -> Result<()> {
810 if let Some(parent) = path.parent()
811 && !parent.exists()
812 {
813 fs::create_dir_all(parent)
814 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
815 }
816
817 let format = Self::detect_format(path);
818 let content = Self::serialize_for_format(self, format)?;
819
820 let tmp_path = path.with_extension("tmp");
822 fs::write(&tmp_path, &content)
823 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
824 fs::rename(&tmp_path, path)
825 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
826
827 Ok(())
828 }
829
830 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
834 let dir = project_dir.join(".oxicode");
835
836 if !dir.exists() {
837 fs::create_dir_all(&dir).with_context(|| {
838 format!(
839 "Failed to create project settings directory {}",
840 dir.display()
841 )
842 })?;
843 }
844
845 let json_path = dir.join("settings.json");
847 let toml_path = dir.join("settings.toml");
848
849 let path = if json_path.exists() {
850 &json_path
851 } else if toml_path.exists() {
852 &toml_path
853 } else {
854 &json_path
856 };
857
858 let format = Self::detect_format(path);
859 let content = Self::serialize_for_format(self, format)?;
860
861 let tmp_path = path.with_extension("tmp");
863 fs::write(&tmp_path, &content)
864 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
865 fs::rename(&tmp_path, path)
866 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
867
868 Ok(())
869 }
870
871 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
873 match format {
874 SettingsFormat::Toml => {
875 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
876 }
877 SettingsFormat::Json => serde_json::to_string_pretty(settings)
878 .context("Failed to serialize settings to JSON"),
879 }
880 }
881
882 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
884 match format {
885 SettingsFormat::Toml => {
886 toml::from_str(content).context("Failed to parse TOML settings")
887 }
888 SettingsFormat::Json => {
889 serde_json::from_str(content).context("Failed to parse JSON settings")
890 }
891 }
892 }
893
894 pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
903 if let Some(m) = model {
904 self.last_used_model = Some(m);
905 }
906 if let Some(p) = provider {
907 self.last_used_provider = Some(p);
908 }
909 }
910
911 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
914 cli_model.map(String::from).or_else(|| {
915 let model = self.last_used_model.as_ref()?;
920 if model.contains('/') {
921 Some(model.clone())
923 } else if let Some(ref provider) = self.last_used_provider {
924 Some(format!("{}/{}", provider, model))
926 } else {
927 Some(model.clone())
928 }
929 })
930 }
931
932 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
935 cli_provider
936 .map(String::from)
937 .or_else(|| self.last_used_provider.clone())
938 }
939
940 pub fn effective_temperature(&self) -> Option<f64> {
943 self.default_temperature
944 .or(self.temperature.map(|t| t as f64))
945 }
946
947 pub fn effective_max_tokens(&self) -> Option<usize> {
950 self.max_response_tokens
951 .or(self.max_tokens.map(|t| t as usize))
952 }
953
954 pub fn save_last_used(model_id: &str) {
960 if let Ok(mut settings) = Self::load() {
961 if let Some((provider, model)) = model_id.split_once('/') {
962 settings.last_used_provider = Some(provider.to_string());
963 settings.last_used_model = Some(model.to_string());
964 } else {
965 settings.last_used_model = Some(model_id.to_string());
966 }
967 let _ = settings.save();
968 }
969 }
970
971 pub fn save_theme(&mut self, name: &str) -> Result<()> {
973 self.theme = name.to_string();
974 self.save()
975 }
976
977 pub fn get_theme_name(&self) -> String {
979 if self.theme.is_empty() || self.theme == "default" {
980 "oxi".to_string()
981 } else {
982 self.theme.clone()
983 }
984 }
985
986 fn migrate(settings: Settings) -> Result<Settings> {
1000 let mut settings = settings;
1001
1002 match settings.version {
1003 SETTINGS_VERSION => {
1004 }
1006 0 => {
1007 if settings.tool_timeout_seconds == 0 {
1010 settings.tool_timeout_seconds = default_tool_timeout();
1011 }
1012 settings.version = SETTINGS_VERSION;
1013
1014 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1015 }
1016 1 | 2 => {
1017 settings.version = SETTINGS_VERSION;
1021 tracing::info!(
1022 "Migrated settings from version {} to {}",
1023 settings.version,
1024 SETTINGS_VERSION
1025 );
1026 }
1027 3 => {
1028 if let Some(model) = settings.default_model.take() {
1031 if let Some((provider, model_name)) = model.split_once('/') {
1032 settings.last_used_provider = Some(provider.to_string());
1033 settings.last_used_model = Some(model_name.to_string());
1034 } else {
1035 settings.last_used_model = Some(model);
1036 }
1037 }
1038 settings.version = SETTINGS_VERSION;
1039 tracing::info!(
1040 "Migrated settings from version 3 to {} (default_model → last_used_model)",
1041 SETTINGS_VERSION
1042 );
1043 }
1044 4 => {
1045 settings.version = SETTINGS_VERSION;
1047 tracing::info!("Migrated settings from version 4 to {}", SETTINGS_VERSION);
1048 }
1049 5 => {
1050 settings.version = SETTINGS_VERSION;
1052 tracing::info!("Migrated settings from version 5 to {}", SETTINGS_VERSION);
1053 }
1054 6 => {
1055 settings.version = SETTINGS_VERSION;
1058 tracing::info!(
1059 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1060 SETTINGS_VERSION
1061 );
1062 }
1063 7 => {
1064 settings.version = SETTINGS_VERSION;
1067 tracing::info!(
1068 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1069 SETTINGS_VERSION
1070 );
1071 }
1072 8 => {
1073 settings.version = SETTINGS_VERSION;
1076 tracing::info!(
1077 "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1078 SETTINGS_VERSION
1079 );
1080 }
1081 v if v > SETTINGS_VERSION => {
1082 anyhow::bail!(
1084 "Settings version {} is newer than supported version {}. \
1085 Please update oxicode.",
1086 v,
1087 SETTINGS_VERSION
1088 );
1089 }
1090 v => {
1091 tracing::warn!(
1093 "Unknown settings version {}, attempting migration to {}",
1094 v,
1095 SETTINGS_VERSION
1096 );
1097 settings.version = SETTINGS_VERSION;
1098 }
1099 }
1100
1101 Ok(settings)
1102 }
1103}
1104
1105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1109pub enum SettingsFormat {
1110 #[default]
1112 Json,
1113 Toml,
1115}
1116
1117impl SettingsFormat {
1118 pub fn extension(&self) -> &'static str {
1120 match self {
1121 SettingsFormat::Json => "json",
1122 SettingsFormat::Toml => "toml",
1123 }
1124 }
1125}
1126
1127fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1131 match toml {
1132 toml::Value::String(s) => serde_json::Value::String(s),
1133 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1134 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1135 .map(serde_json::Value::Number)
1136 .unwrap_or(serde_json::Value::Null),
1137 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1138 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1139 toml::Value::Array(arr) => {
1140 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1141 }
1142 toml::Value::Table(table) => {
1143 let obj = table
1144 .into_iter()
1145 .map(|(k, v)| (k, toml_value_to_json(v)))
1146 .collect();
1147 serde_json::Value::Object(obj)
1148 }
1149 }
1150}
1151
1152fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1154 match (base, override_) {
1155 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1157 let mut result = base_map;
1158 for (key, override_value) in override_map {
1159 let base_value = result.remove(&key);
1160 let merged = match base_value {
1161 Some(base_v) => merge_json_values(base_v, override_value),
1162 None => override_value,
1163 };
1164 result.insert(key, merged);
1165 }
1166 serde_json::Value::Object(result)
1167 }
1168 (_, override_) => override_,
1170 }
1171}
1172
1173pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1175 match s.to_lowercase().as_str() {
1176 "off" | "none" => Some(ThinkingLevel::Off),
1177 "minimal" => Some(ThinkingLevel::Minimal),
1178 "low" => Some(ThinkingLevel::Low),
1179 "medium" | "standard" => Some(ThinkingLevel::Medium),
1180 "high" | "thorough" => Some(ThinkingLevel::High),
1181 "xhigh" => Some(ThinkingLevel::XHigh),
1182 _ => None,
1183 }
1184}
1185
1186#[allow(dead_code)]
1188fn parse_boolish(s: &str) -> Result<bool> {
1189 match s.to_lowercase().as_str() {
1190 "true" | "1" | "yes" | "on" => Ok(true),
1191 "false" | "0" | "no" | "off" => Ok(false),
1192 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1193 }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 #[test]
1201 fn inline_images_defaults_true_and_reads_override() {
1202 use super::*;
1203 assert!(Settings::default().inline_images, "previews on by default");
1204 let s: Settings = toml::from_str("inline_images = false").unwrap();
1205 assert!(!s.inline_images, "settings file can disable previews");
1206 }
1207
1208 use super::*;
1209 use std::io::Write as IoWrite;
1210 use std::sync::Mutex;
1211
1212 #[test]
1213 fn todo_settings_default_preserve_current_behavior() {
1214 let s = Settings::default();
1215 assert_eq!(s.todo_eager_mode, TodoEagerMode::Off);
1216 assert!(s.todo_reminders_enabled);
1217 assert_eq!(s.todo_reminders_max, 3);
1218 assert_eq!(s.todo_clear_delay_secs, 60);
1219 }
1220
1221 #[test]
1222 fn todo_eager_mode_round_trips_through_toml() {
1223 let parsed: TodoEagerMode = toml::from_str("v = \"always\"")
1224 .map(|t: toml::Value| TodoEagerMode::deserialize(t["v"].clone()).unwrap())
1225 .unwrap();
1226 assert_eq!(parsed, TodoEagerMode::Always);
1227 }
1228
1229 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1232
1233 struct EnvGuard {
1236 saved: Vec<(String, Option<String>)>,
1237 }
1238
1239 impl EnvGuard {
1240 fn new(vars: &[&str]) -> Self {
1241 let saved = vars
1242 .iter()
1243 .map(|&name| {
1244 let old = env::var(name).ok();
1245 unsafe { env::remove_var(name) };
1247 (name.to_string(), old)
1248 })
1249 .collect();
1250 Self { saved }
1251 }
1252 }
1253
1254 impl Drop for EnvGuard {
1255 fn drop(&mut self) {
1256 for (name, old) in self.saved.drain(..) {
1257 match old {
1258 Some(val) => unsafe { env::set_var(&name, val) },
1260 None => unsafe { env::remove_var(&name) },
1261 }
1262 }
1263 }
1264 }
1265
1266 #[test]
1269 fn test_default_settings() {
1270 let settings = Settings::default();
1271 assert_eq!(settings.version, SETTINGS_VERSION);
1272 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1273 assert_eq!(settings.theme, "default");
1274 assert!(settings.last_used_model.is_none());
1275 assert!(settings.last_used_provider.is_none());
1276 assert!(settings.extensions_enabled);
1277 assert!(settings.auto_compaction);
1278 assert_eq!(settings.tool_timeout_seconds, 120);
1279 }
1280
1281 #[test]
1282 fn test_merge_cli() {
1283 let mut settings = Settings::default();
1284 settings.last_used_model = Some("gpt-4o".to_string());
1285
1286 settings.merge_cli(Some("claude".to_string()), None);
1287 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1288
1289 settings.merge_cli(None, Some("google".to_string()));
1290 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1291 }
1292
1293 #[test]
1296 fn test_layer_file_overrides() {
1297 let base = Settings::default();
1298
1299 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1300 let toml_content = r#"
1301last_used_model = "openai/gpt-4o"
1302theme = "dracula"
1303"#;
1304 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1305
1306 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1307 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1308 assert_eq!(merged.theme, "dracula");
1309 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1311 assert!(merged.extensions_enabled);
1312 }
1313
1314 #[test]
1315 fn test_layer_file_preserves_unset() {
1316 let mut base = Settings::default();
1317 base.last_used_provider = Some("deepseek".to_string());
1318
1319 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1320 let toml_content = "theme = \"monokai\"\n";
1322 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1323
1324 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1325 assert_eq!(merged.theme, "monokai");
1326 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1327 }
1328
1329 #[test]
1330 fn test_load_from_dir_with_project_config() {
1331 let _guard = EnvGuard::new(&[
1332 "OXICODE_MODEL",
1333 "OXICODE_PROVIDER",
1334 "OXICODE_THEME",
1335 "OXICODE_TOOL_TIMEOUT",
1336 "OXICODE_TEMPERATURE",
1337 "OXICODE_MAX_TOKENS",
1338 "OXICODE_SESSION_DIR",
1339 "OXICODE_EXTENSIONS_ENABLED",
1340 ]);
1341 let tmp = tempfile::tempdir().unwrap();
1342 let oxicode_dir = tmp.path().join(".oxicode");
1343 fs::create_dir_all(&oxicode_dir).unwrap();
1344 let settings_path = oxicode_dir.join("settings.toml");
1345 fs::write(
1347 &settings_path,
1348 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1349 )
1350 .unwrap();
1351
1352 let settings = Settings::load_from(tmp.path()).unwrap();
1353 assert_eq!(
1355 settings.last_used_model,
1356 Some("gemini-2.0-flash".to_string())
1357 );
1358 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1359 }
1360
1361 #[test]
1362 fn test_load_from_dir_no_config() {
1363 let _guard = EnvGuard::new(&[
1365 "OXICODE_MODEL",
1366 "OXICODE_PROVIDER",
1367 "OXICODE_THEME",
1368 "OXICODE_TOOL_TIMEOUT",
1369 "OXICODE_TEMPERATURE",
1370 "OXICODE_MAX_TOKENS",
1371 "OXICODE_SESSION_DIR",
1372 "OXICODE_EXTENSIONS_ENABLED",
1373 ]);
1374 let tmp = tempfile::tempdir().unwrap();
1375 let global = tmp.path().join("nonexistent-settings.json");
1380 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1381 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1382 }
1383 #[test]
1384 fn test_from_env() {
1385 let _guard = EnvGuard::new(&[
1388 "OXICODE_MODEL",
1390 "OXICODE_THEME",
1391 "OXICODE_TOOL_TIMEOUT",
1392 "OXICODE_PROVIDER",
1393 "OXICODE_DEFAULT_MODEL",
1394 ]);
1395
1396 let settings = Settings::from_env();
1397 assert_eq!(settings.last_used_model, None);
1399 assert_eq!(settings.theme, "default");
1400 assert_eq!(settings.tool_timeout_seconds, 120);
1401 }
1402
1403 #[test]
1404 fn test_apply_env_boolish() {
1405 let _guard = EnvGuard::new(&["OXICODE_EXTENSIONS_ENABLED"]);
1408 unsafe { env::set_var("OXICODE_EXTENSIONS_ENABLED", "0") };
1409
1410 let mut settings = Settings::default();
1411 settings.apply_env();
1412 assert!(settings.extensions_enabled); }
1415
1416 #[test]
1417 fn test_apply_env_temperature() {
1418 let _guard = EnvGuard::new(&["OXICODE_TEMPERATURE"]);
1420 unsafe { env::set_var("OXICODE_TEMPERATURE", "0.7") };
1421
1422 let mut settings = Settings::default();
1423 settings.apply_env();
1424 assert_eq!(settings.default_temperature, None);
1426 }
1427
1428 #[test]
1429 fn test_env_does_not_override_when_unset() {
1430 let _guard = EnvGuard::new(&[
1431 "OXICODE_MODEL",
1432 "OXICODE_PROVIDER",
1433 "OXICODE_THEME",
1434 "OXICODE_TEMPERATURE",
1435 ]);
1436 let settings = Settings::from_env();
1437 assert!(settings.last_used_model.is_none());
1438 assert!(settings.last_used_provider.is_none());
1439 }
1440
1441 #[test]
1442 fn test_parse_thinking_level() {
1443 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1444 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1445 assert_eq!(
1446 parse_thinking_level("MINIMAL"),
1447 Some(ThinkingLevel::Minimal)
1448 );
1449 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1450 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1451 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1452 assert_eq!(
1453 parse_thinking_level("Standard"),
1454 Some(ThinkingLevel::Medium)
1455 );
1456 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1457 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1458 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1459 assert_eq!(parse_thinking_level("invalid"), None);
1460 }
1461
1462 #[test]
1463 fn test_parse_boolish() {
1464 assert!(parse_boolish("true").unwrap());
1465 assert!(parse_boolish("1").unwrap());
1466 assert!(parse_boolish("yes").unwrap());
1467 assert!(parse_boolish("ON").unwrap());
1468 assert!(!parse_boolish("false").unwrap());
1469 assert!(!parse_boolish("0").unwrap());
1470 assert!(!parse_boolish("no").unwrap());
1471 assert!(!parse_boolish("OFF").unwrap());
1472 assert!(parse_boolish("maybe").is_err());
1473 }
1474
1475 #[test]
1478 fn test_effective_model_returns_last_used() {
1479 let mut settings = Settings::default();
1480 settings.last_used_model = Some("openai/gpt-4o".to_string());
1481 assert_eq!(
1482 settings.effective_model(None),
1483 Some("openai/gpt-4o".to_string())
1484 );
1485 }
1486
1487 #[test]
1488 fn test_effective_model_cli_overrides() {
1489 let mut settings = Settings::default();
1490 settings.last_used_model = Some("openai/gpt-4o".to_string());
1491 assert_eq!(
1492 settings.effective_model(Some("anthropic/claude-3")),
1493 Some("anthropic/claude-3".to_string())
1494 );
1495 }
1496
1497 #[test]
1498 fn test_effective_model_none_when_unset() {
1499 let settings = Settings::default();
1500 assert_eq!(settings.effective_model(None), None);
1501 }
1502
1503 #[test]
1504 fn test_effective_model_falls_back_to_last_used() {
1505 let mut settings = Settings::default();
1506 settings.last_used_model = Some("anthropic/claude-3".to_string());
1507 assert_eq!(
1508 settings.effective_model(None),
1509 Some("anthropic/claude-3".to_string())
1510 );
1511 }
1512
1513 #[test]
1514 fn test_effective_model_returns_none_when_nothing_set() {
1515 let settings = Settings::default();
1516 assert_eq!(settings.effective_model(None), None);
1517 }
1518
1519 #[test]
1520 fn test_effective_temperature_prefers_f64() {
1521 let mut settings = Settings::default();
1522 settings.temperature = Some(0.5);
1523 settings.default_temperature = Some(0.7);
1524 assert_eq!(settings.effective_temperature(), Some(0.7));
1525 }
1526
1527 #[test]
1528 fn test_effective_temperature_falls_back_to_f32() {
1529 let mut settings = Settings::default();
1530 settings.temperature = Some(0.5);
1531 assert_eq!(settings.effective_temperature(), Some(0.5));
1532 }
1533
1534 #[test]
1535 fn test_effective_max_tokens_prefers_usize() {
1536 let mut settings = Settings::default();
1537 settings.max_tokens = Some(1024);
1538 settings.max_response_tokens = Some(4096);
1539 assert_eq!(settings.effective_max_tokens(), Some(4096));
1540 }
1541
1542 #[test]
1543 fn test_effective_max_tokens_falls_back_to_u32() {
1544 let mut settings = Settings::default();
1545 settings.max_tokens = Some(1024);
1546 assert_eq!(settings.effective_max_tokens(), Some(1024));
1547 }
1548
1549 #[test]
1552 fn test_effective_session_dir_default() {
1553 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1554 let settings = Settings::default();
1555 let dir = settings.effective_session_dir().unwrap();
1556 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1557 }
1558
1559 #[test]
1560 fn test_effective_session_dir_from_field() {
1561 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1562 let mut settings = Settings::default();
1563 settings.session_dir = Some(PathBuf::from("/tmp/oxicode-sessions"));
1564 assert_eq!(
1565 settings.effective_session_dir().unwrap(),
1566 PathBuf::from("/tmp/oxicode-sessions")
1567 );
1568 }
1569
1570 #[test]
1571 fn test_effective_session_dir_env_disabled() {
1572 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1575 unsafe { env::set_var("OXICODE_SESSION_DIR", "/tmp/env-sessions") };
1576 let settings = Settings::default();
1577 let dir = settings.effective_session_dir().unwrap();
1579 assert!(
1580 dir.ends_with("sessions"),
1581 "expected default sessions dir, got: {:?}",
1582 dir
1583 );
1584 }
1585
1586 #[test]
1589 fn test_migration_v0_to_v1() {
1590 let mut settings = Settings::default();
1591 settings.version = 0;
1592 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1595 assert_eq!(migrated.version, SETTINGS_VERSION);
1596 assert_eq!(migrated.tool_timeout_seconds, 120);
1597 }
1598
1599 #[test]
1600 fn test_migration_already_current() {
1601 let settings = Settings::default();
1602 let migrated = Settings::migrate(settings).unwrap();
1603 assert_eq!(migrated.version, SETTINGS_VERSION);
1604 }
1605
1606 #[test]
1607 fn test_migration_v3_to_v4_splits_model() {
1608 let mut settings = Settings::default();
1609 settings.version = 3;
1610 settings.default_model = Some("openai/gpt-4o".to_string());
1611 settings.default_provider = None;
1612
1613 let migrated = Settings::migrate(settings).unwrap();
1614 assert_eq!(migrated.version, SETTINGS_VERSION);
1615 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1616 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1617 }
1618
1619 #[test]
1620 fn test_migration_v3_no_slash_keeps_model() {
1621 let mut settings = Settings::default();
1622 settings.version = 3;
1623 settings.default_model = Some("bare-model-name".to_string());
1624
1625 let migrated = Settings::migrate(settings).unwrap();
1626 assert_eq!(migrated.version, SETTINGS_VERSION);
1627 assert_eq!(
1628 migrated.last_used_model,
1629 Some("bare-model-name".to_string())
1630 );
1631 }
1632
1633 #[test]
1634 fn test_migration_future_version_fails() {
1635 let mut settings = Settings::default();
1636 settings.version = 9999;
1637 assert!(Settings::migrate(settings).is_err());
1638 }
1639
1640 #[test]
1641 fn test_default_glyph_set_is_unicode() {
1642 let settings = Settings::default();
1643 assert_eq!(
1644 settings.glyph_set,
1645 GlyphSet::Unicode,
1646 "glyph_set must default to Unicode"
1647 );
1648 }
1649
1650 #[test]
1651 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1652 let mut settings = Settings::default();
1655 settings.version = 7;
1656 settings.glyph_set = GlyphSet::default();
1658
1659 let migrated = Settings::migrate(settings).unwrap();
1660 assert_eq!(migrated.version, SETTINGS_VERSION);
1661 assert_eq!(
1662 migrated.glyph_set,
1663 GlyphSet::Unicode,
1664 "v7 → v8 migration must default glyph_set to unicode"
1665 );
1666 }
1667
1668 #[test]
1669 fn test_glyph_set_persists_through_roundtrip() {
1670 let mut original = Settings::default();
1674 original.glyph_set = GlyphSet::Nerd;
1675 let content = toml::to_string_pretty(&original).unwrap();
1676 assert!(
1677 content.contains("glyph_set = \"nerd\""),
1678 "nerd preset must serialize to snake_case; got:\n{content}"
1679 );
1680 let loaded: Settings = toml::from_str(&content).unwrap();
1681 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1682 original.glyph_set = GlyphSet::Unicode;
1684 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1685 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1686 }
1687
1688 #[test]
1689 fn test_save_and_load_roundtrip() {
1690 let tmp = tempfile::tempdir().unwrap();
1691 let settings_path = tmp.path().join("settings.toml");
1692
1693 let mut original = Settings::default();
1694 original.last_used_model = Some("gpt-4o".to_string());
1695 original.last_used_provider = Some("openai".to_string());
1696 original.theme = "dracula".to_string();
1697 original.tool_timeout_seconds = 60;
1698
1699 let content = toml::to_string_pretty(&original).unwrap();
1701 fs::write(&settings_path, &content).unwrap();
1702
1703 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1705 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1706
1707 assert_eq!(loaded.last_used_model, original.last_used_model);
1708 assert_eq!(loaded.theme, original.theme);
1709 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1710 }
1711
1712 #[test]
1713 fn test_toml_roundtrip_preserves_new_fields() {
1714 let mut settings = Settings::default();
1715 settings.default_temperature = Some(0.8);
1716 settings.max_response_tokens = Some(8192);
1717 settings.auto_compaction = false;
1718 settings.extensions_enabled = false;
1719 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1720
1721 let toml_str = toml::to_string_pretty(&settings).unwrap();
1722 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1723
1724 assert_eq!(parsed.default_temperature, Some(0.8));
1725 assert_eq!(parsed.max_response_tokens, Some(8192));
1726 assert!(!parsed.auto_compaction);
1727 assert!(!parsed.extensions_enabled);
1728 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1729 }
1730
1731 #[test]
1734 fn test_json_roundtrip() {
1735 let mut settings = Settings::default();
1736 settings.last_used_model = Some("gpt-4o".to_string());
1737 settings.last_used_provider = Some("openai".to_string());
1738 settings.theme = "dracula".to_string();
1739 settings.tool_timeout_seconds = 60;
1740 settings.default_temperature = Some(0.8);
1741 settings.max_response_tokens = Some(8192);
1742
1743 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1744 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1745
1746 assert_eq!(parsed.last_used_model, settings.last_used_model);
1747 assert_eq!(parsed.theme, settings.theme);
1748 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1749 assert_eq!(parsed.default_temperature, settings.default_temperature);
1750 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1751 }
1752
1753 #[test]
1754 fn test_json_serialize_for_format() {
1755 let mut settings = Settings::default();
1756 settings.last_used_model = Some("claude-3".to_string());
1757 settings.last_used_provider = Some("anthropic".to_string());
1758 settings.thinking_level = ThinkingLevel::Minimal;
1759
1760 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1761 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1762
1763 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1764 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1765 }
1766
1767 #[test]
1768 fn test_toml_serialize_for_format() {
1769 let mut settings = Settings::default();
1770 settings.last_used_model = Some("gemini-pro".to_string());
1771 settings.last_used_provider = Some("google".to_string());
1772 settings.thinking_level = ThinkingLevel::High;
1773
1774 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1775 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1776
1777 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1778 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1779 }
1780
1781 #[test]
1782 fn test_parse_from_str_json() {
1783 let json_content = r#"{
1784 "last_used_model": "gpt-4",
1785 "last_used_provider": "openai",
1786 "theme": "nord",
1787 "tool_timeout_seconds": 90
1788 }"#;
1789
1790 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1791 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1792 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1793 assert_eq!(settings.theme, "nord");
1794 assert_eq!(settings.tool_timeout_seconds, 90);
1795 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1797 assert!(settings.extensions_enabled);
1798 }
1799
1800 #[test]
1801 fn test_parse_from_str_toml() {
1802 let toml_content = r#"
1803last_used_model = "claude-opus"
1804last_used_provider = "anthropic"
1805theme = "monokai"
1806tool_timeout_seconds = 45
1807"#;
1808
1809 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1810 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1811 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1812 assert_eq!(settings.theme, "monokai");
1813 assert_eq!(settings.tool_timeout_seconds, 45);
1814 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1815 }
1816
1817 #[test]
1818 fn test_layer_file_json() {
1819 let base = Settings::default();
1820
1821 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1822 let json_content = r#"{
1823 "last_used_model": "gpt-4o",
1824 "last_used_provider": "openai",
1825 "theme": "dracula",
1826 "auto_compaction": false
1827 }"#;
1828 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1829
1830 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1831 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1832 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1833 assert_eq!(merged.theme, "dracula");
1834 assert!(!merged.auto_compaction);
1835 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1837 assert!(merged.extensions_enabled);
1838 assert_eq!(merged.tool_timeout_seconds, 120);
1839 }
1840
1841 #[test]
1842 fn test_layer_file_json_preserves_unset() {
1843 let mut base = Settings::default();
1844 base.last_used_provider = Some("deepseek".to_string());
1845
1846 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1847 let json_content = r#"{ "theme": "nord" }"#;
1848 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1849
1850 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1851 assert_eq!(merged.theme, "nord");
1852 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1853 }
1854
1855 #[test]
1856 fn test_save_to_json() {
1857 let tmp = tempfile::tempdir().unwrap();
1858 let settings_path = tmp.path().join("settings.json");
1859
1860 let mut settings = Settings::default();
1861 settings.last_used_model = Some("gpt-4o".to_string());
1862 settings.last_used_provider = Some("openai".to_string());
1863 settings.theme = "dracula".to_string();
1864 settings.tool_timeout_seconds = 60;
1865
1866 settings.save_to(&settings_path).unwrap();
1867
1868 let content = fs::read_to_string(&settings_path).unwrap();
1870 let parsed: Settings = serde_json::from_str(&content).unwrap();
1871 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1872 assert_eq!(parsed.theme, "dracula");
1873 assert_eq!(parsed.tool_timeout_seconds, 60);
1874 }
1875
1876 #[test]
1877 fn test_save_to_toml() {
1878 let tmp = tempfile::tempdir().unwrap();
1879 let settings_path = tmp.path().join("settings.toml");
1880
1881 let mut settings = Settings::default();
1882 settings.last_used_model = Some("gemini-pro".to_string());
1883 settings.last_used_provider = Some("google".to_string());
1884 settings.theme = "monokai".to_string();
1885 settings.tool_timeout_seconds = 90;
1886
1887 settings.save_to(&settings_path).unwrap();
1888
1889 let content = fs::read_to_string(&settings_path).unwrap();
1891 let parsed: Settings = toml::from_str(&content).unwrap();
1892 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1893 assert_eq!(parsed.theme, "monokai");
1894 assert_eq!(parsed.tool_timeout_seconds, 90);
1895 }
1896
1897 #[test]
1898 fn test_load_from_dir_with_json_project_config() {
1899 let _guard = EnvGuard::new(&[
1900 "OXICODE_MODEL",
1901 "OXICODE_PROVIDER",
1902 "OXICODE_THEME",
1903 "OXICODE_TOOL_TIMEOUT",
1904 "OXICODE_TEMPERATURE",
1905 "OXICODE_MAX_TOKENS",
1906 "OXICODE_SESSION_DIR",
1907 "OXICODE_EXTENSIONS_ENABLED",
1908 ]);
1909 let tmp = tempfile::tempdir().unwrap();
1910 let oxicode_dir = tmp.path().join(".oxicode");
1911 fs::create_dir_all(&oxicode_dir).unwrap();
1912 let settings_path = oxicode_dir.join("settings.json");
1913 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
1915 fs::write(&settings_path, json_content).unwrap();
1916
1917 let settings = Settings::load_from(tmp.path()).unwrap();
1918 assert_eq!(
1920 settings.last_used_model,
1921 Some("gemini-2.0-flash".to_string())
1922 );
1923 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1924 }
1925
1926 #[test]
1927 fn test_find_project_settings_json_priority() {
1928 let tmp = tempfile::tempdir().unwrap();
1929 let oxicode_dir = tmp.path().join(".oxicode");
1930 fs::create_dir_all(&oxicode_dir).unwrap();
1931
1932 let json_path = oxicode_dir.join("settings.json");
1934 let toml_path = oxicode_dir.join("settings.toml");
1935 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1936 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1937
1938 let found = Settings::find_project_settings(tmp.path());
1940 assert!(found.is_some());
1941 assert_eq!(
1942 found.unwrap().file_name().unwrap().to_str().unwrap(),
1943 "settings.json"
1944 );
1945 }
1946
1947 #[test]
1948 fn test_find_project_settings_json_only() {
1949 let tmp = tempfile::tempdir().unwrap();
1950 let oxicode_dir = tmp.path().join(".oxicode");
1951 fs::create_dir_all(&oxicode_dir).unwrap();
1952
1953 let json_path = oxicode_dir.join("settings.json");
1954 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1955
1956 let found = Settings::find_project_settings(tmp.path());
1957 assert!(found.is_some());
1958 assert_eq!(
1959 found.unwrap().file_name().unwrap().to_str().unwrap(),
1960 "settings.json"
1961 );
1962 }
1963
1964 #[test]
1965 fn test_find_project_settings_toml_fallback() {
1966 let tmp = tempfile::tempdir().unwrap();
1967 let oxicode_dir = tmp.path().join(".oxicode");
1968 fs::create_dir_all(&oxicode_dir).unwrap();
1969
1970 let toml_path = oxicode_dir.join("settings.toml");
1971 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1972
1973 let found = Settings::find_project_settings(tmp.path());
1974 assert!(found.is_some());
1975 assert_eq!(
1976 found.unwrap().file_name().unwrap().to_str().unwrap(),
1977 "settings.toml"
1978 );
1979 }
1980
1981 #[test]
1982 fn test_detect_format() {
1983 let json_path = PathBuf::from("/test/settings.json");
1984 let toml_path = PathBuf::from("/test/settings.toml");
1985 let unknown_path = PathBuf::from("/test/settings");
1986
1987 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1988 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1989 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
1990 }
1992
1993 #[test]
1994 fn test_settings_format_extension() {
1995 assert_eq!(SettingsFormat::Json.extension(), "json");
1996 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1997 }
1998
1999 #[test]
2000 fn test_layer_json_over_toml() {
2001 let tmp = tempfile::tempdir().unwrap();
2003 let oxicode_dir = tmp.path().join(".oxicode");
2004 fs::create_dir_all(&oxicode_dir).unwrap();
2005
2006 let json_path = oxicode_dir.join("settings.json");
2007 let toml_path = oxicode_dir.join("settings.toml");
2008
2009 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2011 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2013
2014 let settings = Settings::load_from(tmp.path()).unwrap();
2016 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2017 }
2018
2019 #[test]
2020 fn test_mixed_format_loading() {
2021 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2023 let toml_content = r#"
2024last_used_model = "loaded-via-toml"
2025theme = "loaded-theme"
2026"#;
2027 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2028
2029 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2030 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2031 assert_eq!(merged.theme, "loaded-theme");
2032 }
2033
2034 #[test]
2035 fn test_merge_json_values() {
2036 let base = serde_json::json!({
2037 "version": 1,
2038 "theme": "default",
2039 "extensions": ["ext1"],
2040 "nested": {
2041 "a": 1,
2042 "b": 2
2043 }
2044 });
2045
2046 let override_ = serde_json::json!({
2047 "version": 2,
2048 "theme": "dark",
2049 "extensions": ["ext2"],
2050 "nested": {
2051 "b": 20,
2052 "c": 30
2053 }
2054 });
2055
2056 let merged = merge_json_values(base, override_);
2057
2058 assert_eq!(merged["version"], 2);
2059 assert_eq!(merged["theme"], "dark");
2060 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2062 assert_eq!(merged["nested"]["a"], 1);
2064 assert_eq!(merged["nested"]["b"], 20);
2065 assert_eq!(merged["nested"]["c"], 30);
2066 }
2067
2068 #[test]
2069 fn test_save_project_preserves_existing_format() {
2070 let tmp = tempfile::tempdir().unwrap();
2071 let oxicode_dir = tmp.path().join(".oxicode");
2072 fs::create_dir_all(&oxicode_dir).unwrap();
2073
2074 let toml_path = oxicode_dir.join("settings.toml");
2076 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2077
2078 let mut settings = Settings::default();
2079 settings.theme = "new-theme".to_string();
2080 settings.save_project(tmp.path()).unwrap();
2081
2082 let content = fs::read_to_string(&toml_path).unwrap();
2084 assert!(content.contains("new-theme"));
2085 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2086 }
2087
2088 #[test]
2089 fn test_save_project_creates_json_by_default() {
2090 let tmp = tempfile::tempdir().unwrap();
2091 let oxicode_dir = tmp.path().join(".oxicode");
2092 fs::create_dir_all(&oxicode_dir).unwrap();
2093 let mut settings = Settings::default();
2096 settings.theme = "json-theme".to_string();
2097 settings.save_project(tmp.path()).unwrap();
2098
2099 let json_path = oxicode_dir.join("settings.json");
2101 assert!(json_path.exists());
2102 let content = fs::read_to_string(&json_path).unwrap();
2103 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2104 assert!(content.contains("json-theme"));
2105 }
2106
2107 #[test]
2110 fn test_custom_provider_default_api() {
2111 use super::CustomProvider;
2112 let cp = CustomProvider {
2113 name: "test".to_string(),
2114 base_url: "https://api.test.com/v1".to_string(),
2115 api_key_env: "TEST_API_KEY".to_string(),
2116 api: super::default_custom_provider_api(),
2117 };
2118 assert_eq!(cp.api, "openai-completions");
2119 }
2120
2121 #[test]
2122 fn test_custom_provider_toml_deserialize() {
2123 let toml_content = r#"
2124[[custom_providers]]
2125name = "minimax"
2126base_url = "https://api.minimax.chat/v1"
2127api_key_env = "MINIMAX_API_KEY"
2128api = "openai-completions"
2129
2130[[custom_providers]]
2131name = "zai"
2132base_url = "https://api.z.ai/v1"
2133api_key_env = "ZAI_API_KEY"
2134api = "openai-responses"
2135"#;
2136 let settings: Settings = toml::from_str(toml_content).unwrap();
2137 assert_eq!(settings.custom_providers.len(), 2);
2138 assert_eq!(settings.custom_providers[0].name, "minimax");
2139 assert_eq!(
2140 settings.custom_providers[0].base_url,
2141 "https://api.minimax.chat/v1"
2142 );
2143 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2144 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2145 assert_eq!(settings.custom_providers[1].name, "zai");
2146 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2147 }
2148
2149 #[test]
2150 fn test_custom_provider_json_deserialize() {
2151 let json_content = r#"{
2152 "custom_providers": [
2153 {
2154 "name": "minimax",
2155 "base_url": "https://api.minimax.chat/v1",
2156 "api_key_env": "MINIMAX_API_KEY",
2157 "api": "openai-completions"
2158 }
2159 ]
2160 }"#;
2161 let settings: Settings = serde_json::from_str(json_content).unwrap();
2162 assert_eq!(settings.custom_providers.len(), 1);
2163 assert_eq!(settings.custom_providers[0].name, "minimax");
2164 }
2165
2166 #[test]
2167 fn test_custom_provider_toml_roundtrip() {
2168 let mut settings = Settings::default();
2169 settings.custom_providers.push(super::CustomProvider {
2170 name: "test".to_string(),
2171 base_url: "https://api.test.com/v1".to_string(),
2172 api_key_env: "TEST_API_KEY".to_string(),
2173 api: "openai-completions".to_string(),
2174 });
2175
2176 let toml_str = toml::to_string_pretty(&settings).unwrap();
2177 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2178 assert_eq!(parsed.custom_providers.len(), 1);
2179 assert_eq!(parsed.custom_providers[0].name, "test");
2180 assert_eq!(
2181 parsed.custom_providers[0].base_url,
2182 "https://api.test.com/v1"
2183 );
2184 }
2185
2186 #[test]
2187 fn test_custom_provider_defaults_empty() {
2188 let settings = Settings::default();
2189 assert!(settings.custom_providers.is_empty());
2190 }
2191
2192 #[test]
2193 fn test_custom_provider_layer_file() {
2194 let base = Settings::default();
2195
2196 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2197 let toml_content = r#"
2198[[custom_providers]]
2199name = "my-provider"
2200base_url = "https://api.my-provider.com/v1"
2201api_key_env = "MY_PROVIDER_API_KEY"
2202"#;
2203 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2204
2205 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2206 assert_eq!(merged.custom_providers.len(), 1);
2207 assert_eq!(merged.custom_providers[0].name, "my-provider");
2208 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2210 }
2211
2212 #[test]
2213 fn settings_deserialise_hooks_array() {
2214 let toml = r#"
2215 [[hooks]]
2216 event = "PreToolUse"
2217 matcher = "bash|write"
2218 command = "echo pre"
2219 timeout_secs = 10
2220 "#;
2221 let s: Settings = toml::from_str(toml).unwrap();
2222 assert_eq!(s.hooks.len(), 1);
2223 assert_eq!(s.hooks[0].event, oxicode_sdk::ports::HookEvent::PreToolUse);
2224 assert_eq!(s.hooks[0].matcher.as_deref(), Some("bash|write"));
2225 assert_eq!(s.hooks[0].command, "echo pre");
2226 assert_eq!(s.hooks[0].timeout_secs, Some(10));
2227 }
2228
2229 #[test]
2230 fn settings_default_has_no_hooks() {
2231 let s = Settings::default();
2232 assert!(s.hooks.is_empty());
2233 }
2234}