1use crate::symbols::GlyphSet;
23use anyhow::{Context, Result};
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::env;
27use std::fs;
28use std::path::{Path, PathBuf};
29
30const SETTINGS_VERSION: u32 = 10;
45
46#[allow(dead_code)]
49const ENV_PREFIX: &str = "OXICODE_";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
53#[serde(rename_all = "snake_case")]
54pub enum ThinkingLevel {
55 #[default]
57 Off,
58 Minimal,
60 Low,
62 Medium,
64 High,
66 XHigh,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76#[serde(rename_all = "snake_case")]
77pub enum EditFormat {
78 #[default]
80 Hashline,
81 StrReplace,
83}
84#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CustomProvider {
90 pub name: String,
92 pub base_url: String,
94 pub api_key_env: String,
96 #[serde(default = "default_custom_provider_api")]
98 pub api: String,
99}
100
101pub(crate) fn default_custom_provider_api() -> String {
102 "openai-completions".to_string()
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum TodoEagerMode {
111 #[default]
113 Off,
114 Preferred,
116 Always,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct Settings {
124 #[serde(default)]
127 pub version: u32,
128
129 #[serde(default = "default_thinking_level")]
132 pub thinking_level: ThinkingLevel,
133 #[serde(default = "default_theme")]
135 pub theme: String,
136
137 #[serde(default)]
144 pub glyph_set: GlyphSet,
145
146 #[serde(default, skip_serializing)]
148 pub default_model: Option<String>,
149
150 #[serde(default, skip_serializing)]
152 pub default_provider: Option<String>,
153
154 #[serde(default)]
157 pub last_used_model: Option<String>,
158
159 #[serde(default)]
161 pub last_used_provider: Option<String>,
162
163 pub max_tokens: Option<u32>,
165
166 pub temperature: Option<f32>,
168
169 pub default_temperature: Option<f64>,
171
172 pub max_response_tokens: Option<usize>,
174
175 #[serde(default = "default_session_history_size")]
178 pub session_history_size: usize,
179
180 pub session_dir: Option<PathBuf>,
182
183 #[serde(default = "default_true")]
186 pub extensions_enabled: bool,
187
188 #[serde(default = "default_true")]
190 pub auto_compaction: bool,
191
192 #[serde(default)]
195 pub disabled_tools: Vec<String>,
196
197 #[serde(default = "default_tool_timeout")]
200 pub tool_timeout_seconds: u64,
201
202 #[serde(default, alias = "questionnaire_timeout_secs")]
205 pub ask_timeout_secs: u64,
206
207 #[serde(default)]
210 pub extensions: Vec<String>,
211
212 #[serde(default)]
214 pub skills: Vec<String>,
215
216 #[serde(default)]
218 pub prompts: Vec<String>,
219
220 #[serde(default)]
222 pub themes: Vec<String>,
223
224 #[serde(default)]
227 pub custom_providers: Vec<CustomProvider>,
228
229 #[serde(default)]
234 pub dynamic_models: HashMap<String, Vec<String>>,
235
236 #[serde(default)]
241 pub keybindings: HashMap<String, Vec<String>>,
242
243 #[serde(default)]
252 pub edit_format: EditFormat,
253
254 #[serde(default = "default_true")]
258 pub todo_panel_enabled: bool,
259
260 #[serde(default)]
263 pub todo_eager_mode: TodoEagerMode,
264
265 #[serde(default = "default_true")]
267 pub todo_reminders_enabled: bool,
268
269 #[serde(default = "default_todo_reminders_max")]
271 pub todo_reminders_max: u32,
272
273 #[serde(default = "default_todo_clear_delay_secs")]
276 pub todo_clear_delay_secs: i64,
277
278 #[serde(default = "default_true")]
281 pub agent_hub_enabled: bool,
282
283 #[serde(default = "default_false")]
286 pub snapcompact_enabled: bool,
287
288 #[serde(default = "default_true")]
291 pub mermaid_render_enabled: bool,
292
293 #[serde(default = "default_true")]
297 pub inline_images: bool,
298
299 #[serde(default = "default_false")]
302 pub commit_tool_enabled: bool,
303
304 #[serde(default = "default_false")]
320 pub bash_pty: bool,
321
322 #[serde(default = "default_true")]
328 pub memory_enabled: bool,
329
330 #[serde(default = "default_false")]
334 pub ttsr_enabled: bool,
335
336 #[serde(default = "default_ttsr_mode")]
338 pub ttsr_interrupt_mode: String,
339
340 #[serde(default)]
351 pub model_roles: HashMap<String, String>,
352 #[serde(default)]
356 pub advisor: AdvisorSettings,
357
358 #[serde(default)]
363 pub hooks: Vec<oxicode_sdk::ports::HookSpec>,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct AdvisorSettings {
374 #[serde(default = "default_false")]
376 pub enabled: bool,
377 #[serde(default = "default_advisor_sync_backlog")]
381 pub sync_backlog: String,
382 #[serde(default)]
386 pub immune_turns: u64,
387}
388
389impl Default for AdvisorSettings {
390 fn default() -> Self {
391 Self {
392 enabled: false,
393 sync_backlog: default_advisor_sync_backlog(),
394 immune_turns: 0,
395 }
396 }
397}
398
399fn default_advisor_sync_backlog() -> String {
400 "off".to_string()
401}
402
403fn default_theme() -> String {
404 "default".to_string()
405}
406
407fn default_thinking_level() -> ThinkingLevel {
408 ThinkingLevel::Medium
409}
410
411fn default_session_history_size() -> usize {
412 100
413}
414
415fn default_true() -> bool {
416 true
417}
418
419fn default_false() -> bool {
420 false
421}
422fn default_todo_reminders_max() -> u32 {
423 3
424}
425
426fn default_todo_clear_delay_secs() -> i64 {
427 60
428}
429
430fn default_ttsr_mode() -> String {
431 "prose_only".to_string()
432}
433
434fn default_tool_timeout() -> u64 {
435 120
436}
437
438impl Default for Settings {
439 fn default() -> Self {
440 Self {
441 version: SETTINGS_VERSION,
442 thinking_level: ThinkingLevel::Medium,
443 theme: default_theme(),
444 glyph_set: GlyphSet::default(),
445 last_used_model: None,
446 last_used_provider: None,
447 default_model: None,
448 default_provider: None,
449 max_tokens: None,
450 temperature: None,
451 default_temperature: None,
452 max_response_tokens: None,
453 session_history_size: default_session_history_size(),
454 session_dir: None,
455 extensions_enabled: true,
456 auto_compaction: true,
457 disabled_tools: Vec::new(),
458 tool_timeout_seconds: default_tool_timeout(),
459 ask_timeout_secs: 0,
460 extensions: Vec::new(),
461 skills: Vec::new(),
462 prompts: Vec::new(),
463 themes: Vec::new(),
464 custom_providers: Vec::new(),
465 dynamic_models: HashMap::new(),
466 keybindings: HashMap::new(),
467 edit_format: EditFormat::default(),
468 memory_enabled: true,
469 todo_panel_enabled: true,
470 todo_eager_mode: TodoEagerMode::Off,
471 todo_reminders_enabled: true,
472 todo_reminders_max: default_todo_reminders_max(),
473 todo_clear_delay_secs: default_todo_clear_delay_secs(),
474 agent_hub_enabled: true,
475 snapcompact_enabled: false,
476 advisor: AdvisorSettings::default(),
477 mermaid_render_enabled: true,
478 inline_images: true,
479 commit_tool_enabled: false,
480 bash_pty: false,
481 ttsr_enabled: false,
482 ttsr_interrupt_mode: default_ttsr_mode(),
483 model_roles: HashMap::new(),
484 hooks: Vec::new(),
485 }
486 }
487}
488
489impl Settings {
490 pub fn settings_dir() -> Result<PathBuf> {
495 oxicode_catalog::product_env::home_dir().context("Cannot determine oxicode home directory")
496 }
497
498 fn canonical_settings_path() -> Result<PathBuf> {
504 let json_path = Self::settings_json_path()?;
505 if json_path.exists() {
506 return Ok(json_path);
507 }
508 let toml_path = Self::settings_toml_path()?;
509 if toml_path.exists() {
510 return Ok(toml_path);
511 }
512 Ok(json_path)
513 }
514
515 fn legacy_settings_dir() -> Option<PathBuf> {
520 oxicode_catalog::oxi_home::legacy_home_dir()
521 }
522
523 fn resolve_settings_path_in(
530 canonical_dir: &std::path::Path,
531 legacy_dir: Option<&std::path::Path>,
532 prefer_json: bool,
533 ) -> PathBuf {
534 let json = canonical_dir.join("settings.json");
535 let toml = canonical_dir.join("settings.toml");
536 let (primary, secondary) = if prefer_json {
537 (&json, &toml)
538 } else {
539 (&toml, &json)
540 };
541 if primary.exists() {
542 return primary.clone();
543 }
544 if secondary.exists() {
545 return secondary.clone();
546 }
547 if let Some(legacy) = legacy_dir {
548 let legacy_json = legacy.join("settings.json");
549 let legacy_toml = legacy.join("settings.toml");
550 let (legacy_primary, legacy_secondary) = if prefer_json {
551 (&legacy_json, &legacy_toml)
552 } else {
553 (&legacy_toml, &legacy_json)
554 };
555 if legacy_primary.exists() {
556 return legacy_primary.clone();
557 }
558 if legacy_secondary.exists() {
559 return legacy_secondary.clone();
560 }
561 }
562 primary.clone()
563 }
564
565 pub fn settings_toml_path() -> Result<PathBuf> {
567 Ok(Self::settings_dir()?.join("settings.toml"))
568 }
569
570 pub fn settings_json_path() -> Result<PathBuf> {
572 Ok(Self::settings_dir()?.join("settings.json"))
573 }
574
575 pub fn settings_path() -> Result<PathBuf> {
584 let dir = Self::settings_dir()?;
585 Ok(Self::resolve_settings_path_in(
586 &dir,
587 Self::legacy_settings_dir().as_deref(),
588 true,
589 ))
590 }
591
592 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
599 let dir = Self::settings_dir()?;
600 Ok(Self::resolve_settings_path_in(
601 &dir,
602 Self::legacy_settings_dir().as_deref(),
603 prefer_json,
604 ))
605 }
606
607 pub fn detect_format(path: &Path) -> SettingsFormat {
609 match path.extension().and_then(|e| e.to_str()) {
610 Some("json") => SettingsFormat::Json,
611 Some("toml") => SettingsFormat::Toml,
612 _ => SettingsFormat::Json, }
614 }
615
616 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
621 let mut dir = start_dir.to_path_buf();
622 loop {
623 let json_candidate = dir.join(".oxicode").join("settings.json");
625 if json_candidate.exists() {
626 return Some(json_candidate);
627 }
628
629 let toml_candidate = dir.join(".oxicode").join("settings.toml");
630 if toml_candidate.exists() {
631 return Some(toml_candidate);
632 }
633
634 if !dir.pop() {
635 return None;
636 }
637 }
638 }
639
640 pub fn effective_session_dir(&self) -> Result<PathBuf> {
644 if let Some(ref dir) = self.session_dir {
645 return Ok(dir.clone());
646 }
647 Ok(Self::settings_dir()?.join("sessions"))
648 }
649
650 pub fn load() -> Result<Self> {
669 Self::load_from_cwd()
670 }
671
672 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
678 Self::load_from_with(dir, None)
679 }
680
681 pub fn load_from_with(
701 dir: &std::path::Path,
702 global_override: Option<&std::path::Path>,
703 ) -> Result<Self> {
704 let mut settings = Settings::default();
706
707 let resolved_global: Option<std::path::PathBuf> = match global_override {
710 Some(p) => Some(p.to_path_buf()),
711 None => Self::settings_path().ok(),
712 };
713 if let Some(ref gp) = resolved_global
714 && gp.exists()
715 {
716 settings = Self::layer_file(&settings, gp)?;
717 }
718
719 if let Some(project_path) = Self::find_project_settings(dir) {
721 settings = Self::layer_file(&settings, &project_path)?;
722 }
723
724 settings.apply_env();
726
727 settings = Self::migrate(settings)?;
729
730 if settings.bash_pty {
736 tracing::warn!(
737 "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."
738 );
739 }
740
741 Ok(settings)
742 }
743
744 pub fn load_from_cwd() -> Result<Self> {
746 let cwd = env::current_dir().context("Cannot determine current directory")?;
747 Self::load_from(&cwd)
748 }
749
750 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
756 let content = fs::read_to_string(path)
757 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
758
759 let format = Self::detect_format(path);
760 let overlay: serde_json::Value = match format {
761 SettingsFormat::Toml => {
762 let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
763 format!("Failed to parse TOML settings from {}", path.display())
764 })?;
765 toml_value_to_json(toml_value)
767 }
768 SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
769 format!("Failed to parse JSON settings from {}", path.display())
770 })?,
771 };
772
773 let base_json =
777 serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
778
779 let merged = merge_json_values(base_json, overlay);
780 let result: Settings =
781 serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
782
783 Ok(result)
784 }
785
786 #[allow(dead_code)]
811 pub fn apply_env(&mut self) {
812 }
816
817 #[allow(dead_code)]
823 pub fn from_env() -> Self {
824 Self::default()
825 }
826
827 pub fn save(&self) -> Result<()> {
836 let dir = Self::settings_dir()?;
837 let path = Self::canonical_settings_path()?;
838
839 if !dir.exists() {
840 fs::create_dir_all(&dir).with_context(|| {
841 format!("Failed to create settings directory {}", dir.display())
842 })?;
843 }
844
845 let format = Self::detect_format(&path);
846 let content = Self::serialize_for_format(self, format)?;
847
848 let tmp_path = path.with_extension("tmp");
850 fs::write(&tmp_path, &content)
851 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
852 fs::rename(&tmp_path, &path)
853 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
854
855 Ok(())
856 }
857
858 pub fn save_to(&self, path: &Path) -> Result<()> {
860 if let Some(parent) = path.parent()
861 && !parent.exists()
862 {
863 fs::create_dir_all(parent)
864 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
865 }
866
867 let format = Self::detect_format(path);
868 let content = Self::serialize_for_format(self, format)?;
869
870 let tmp_path = path.with_extension("tmp");
872 fs::write(&tmp_path, &content)
873 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
874 fs::rename(&tmp_path, path)
875 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
876
877 Ok(())
878 }
879
880 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
884 let dir = project_dir.join(".oxicode");
885
886 if !dir.exists() {
887 fs::create_dir_all(&dir).with_context(|| {
888 format!(
889 "Failed to create project settings directory {}",
890 dir.display()
891 )
892 })?;
893 }
894
895 let json_path = dir.join("settings.json");
897 let toml_path = dir.join("settings.toml");
898
899 let path = if json_path.exists() {
900 &json_path
901 } else if toml_path.exists() {
902 &toml_path
903 } else {
904 &json_path
906 };
907
908 let format = Self::detect_format(path);
909 let content = Self::serialize_for_format(self, format)?;
910
911 let tmp_path = path.with_extension("tmp");
913 fs::write(&tmp_path, &content)
914 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
915 fs::rename(&tmp_path, path)
916 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
917
918 Ok(())
919 }
920
921 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
923 match format {
924 SettingsFormat::Toml => {
925 toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
926 }
927 SettingsFormat::Json => serde_json::to_string_pretty(settings)
928 .context("Failed to serialize settings to JSON"),
929 }
930 }
931
932 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
934 match format {
935 SettingsFormat::Toml => {
936 toml::from_str(content).context("Failed to parse TOML settings")
937 }
938 SettingsFormat::Json => {
939 serde_json::from_str(content).context("Failed to parse JSON settings")
940 }
941 }
942 }
943
944 pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
953 if let Some(m) = model {
954 self.last_used_model = Some(m);
955 }
956 if let Some(p) = provider {
957 self.last_used_provider = Some(p);
958 }
959 }
960
961 pub fn effective_model(&self, cli_model: Option<&str>) -> Option<String> {
964 cli_model.map(String::from).or_else(|| {
965 let model = self.last_used_model.as_ref()?;
970 if model.contains('/') {
971 Some(model.clone())
973 } else if let Some(ref provider) = self.last_used_provider {
974 Some(format!("{}/{}", provider, model))
976 } else {
977 Some(model.clone())
978 }
979 })
980 }
981
982 pub fn effective_provider(&self, cli_provider: Option<&str>) -> Option<String> {
985 cli_provider
986 .map(String::from)
987 .or_else(|| self.last_used_provider.clone())
988 }
989
990 pub fn effective_temperature(&self) -> Option<f64> {
993 self.default_temperature
994 .or(self.temperature.map(|t| t as f64))
995 }
996
997 pub fn effective_max_tokens(&self) -> Option<usize> {
1000 self.max_response_tokens
1001 .or(self.max_tokens.map(|t| t as usize))
1002 }
1003
1004 pub fn save_last_used(model_id: &str) {
1010 if let Ok(mut settings) = Self::load() {
1011 if let Some((provider, model)) = model_id.split_once('/') {
1012 settings.last_used_provider = Some(provider.to_string());
1013 settings.last_used_model = Some(model.to_string());
1014 } else {
1015 settings.last_used_model = Some(model_id.to_string());
1016 }
1017 let _ = settings.save();
1018 }
1019 }
1020
1021 pub fn save_theme(&mut self, name: &str) -> Result<()> {
1023 self.theme = name.to_string();
1024 self.save()
1025 }
1026
1027 pub fn get_theme_name(&self) -> String {
1029 if self.theme.is_empty() || self.theme == "default" {
1030 "oxi".to_string()
1031 } else {
1032 self.theme.clone()
1033 }
1034 }
1035
1036 fn migrate(settings: Settings) -> Result<Settings> {
1050 let mut settings = settings;
1051
1052 match settings.version {
1053 SETTINGS_VERSION => {
1054 }
1056 0 => {
1057 if settings.tool_timeout_seconds == 0 {
1060 settings.tool_timeout_seconds = default_tool_timeout();
1061 }
1062 settings.version = SETTINGS_VERSION;
1063
1064 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
1065 }
1066 1 | 2 => {
1067 settings.version = SETTINGS_VERSION;
1071 tracing::info!(
1072 "Migrated settings from version {} to {}",
1073 settings.version,
1074 SETTINGS_VERSION
1075 );
1076 }
1077 3 => {
1078 if let Some(model) = settings.default_model.take() {
1081 if let Some((provider, model_name)) = model.split_once('/') {
1082 settings.last_used_provider = Some(provider.to_string());
1083 settings.last_used_model = Some(model_name.to_string());
1084 } else {
1085 settings.last_used_model = Some(model);
1086 }
1087 }
1088 settings.version = SETTINGS_VERSION;
1089 tracing::info!(
1090 "Migrated settings from version 3 to {} (default_model → last_used_model)",
1091 SETTINGS_VERSION
1092 );
1093 }
1094 4 => {
1095 settings.version = SETTINGS_VERSION;
1097 tracing::info!("Migrated settings from version 4 to {}", SETTINGS_VERSION);
1098 }
1099 5 => {
1100 settings.version = SETTINGS_VERSION;
1102 tracing::info!("Migrated settings from version 5 to {}", SETTINGS_VERSION);
1103 }
1104 6 => {
1105 settings.version = SETTINGS_VERSION;
1108 tracing::info!(
1109 "Migrated settings from version 6 to {} (added edit_format, defaulting to str_replace)",
1110 SETTINGS_VERSION
1111 );
1112 }
1113 7 => {
1114 settings.version = SETTINGS_VERSION;
1117 tracing::info!(
1118 "Migrated settings from version 7 to {} (added glyph_set, defaulting to unicode)",
1119 SETTINGS_VERSION
1120 );
1121 }
1122 8 => {
1123 settings.version = SETTINGS_VERSION;
1126 tracing::info!(
1127 "Migrated settings from version 8 to {} (added model_roles, defaulting to empty)",
1128 SETTINGS_VERSION
1129 );
1130 }
1131 v if v > SETTINGS_VERSION => {
1132 anyhow::bail!(
1134 "Settings version {} is newer than supported version {}. \
1135 Please update oxicode.",
1136 v,
1137 SETTINGS_VERSION
1138 );
1139 }
1140 v => {
1141 tracing::warn!(
1143 "Unknown settings version {}, attempting migration to {}",
1144 v,
1145 SETTINGS_VERSION
1146 );
1147 settings.version = SETTINGS_VERSION;
1148 }
1149 }
1150
1151 Ok(settings)
1152 }
1153}
1154
1155#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1159pub enum SettingsFormat {
1160 #[default]
1162 Json,
1163 Toml,
1165}
1166
1167impl SettingsFormat {
1168 pub fn extension(&self) -> &'static str {
1170 match self {
1171 SettingsFormat::Json => "json",
1172 SettingsFormat::Toml => "toml",
1173 }
1174 }
1175}
1176
1177fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
1181 match toml {
1182 toml::Value::String(s) => serde_json::Value::String(s),
1183 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
1184 toml::Value::Float(f) => serde_json::Number::from_f64(f)
1185 .map(serde_json::Value::Number)
1186 .unwrap_or(serde_json::Value::Null),
1187 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
1188 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
1189 toml::Value::Array(arr) => {
1190 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
1191 }
1192 toml::Value::Table(table) => {
1193 let obj = table
1194 .into_iter()
1195 .map(|(k, v)| (k, toml_value_to_json(v)))
1196 .collect();
1197 serde_json::Value::Object(obj)
1198 }
1199 }
1200}
1201
1202fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
1204 match (base, override_) {
1205 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
1207 let mut result = base_map;
1208 for (key, override_value) in override_map {
1209 let base_value = result.remove(&key);
1210 let merged = match base_value {
1211 Some(base_v) => merge_json_values(base_v, override_value),
1212 None => override_value,
1213 };
1214 result.insert(key, merged);
1215 }
1216 serde_json::Value::Object(result)
1217 }
1218 (_, override_) => override_,
1220 }
1221}
1222
1223pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1225 match s.to_lowercase().as_str() {
1226 "off" | "none" => Some(ThinkingLevel::Off),
1227 "minimal" => Some(ThinkingLevel::Minimal),
1228 "low" => Some(ThinkingLevel::Low),
1229 "medium" | "standard" => Some(ThinkingLevel::Medium),
1230 "high" | "thorough" => Some(ThinkingLevel::High),
1231 "xhigh" => Some(ThinkingLevel::XHigh),
1232 _ => None,
1233 }
1234}
1235
1236#[allow(dead_code)]
1238fn parse_boolish(s: &str) -> Result<bool> {
1239 match s.to_lowercase().as_str() {
1240 "true" | "1" | "yes" | "on" => Ok(true),
1241 "false" | "0" | "no" | "off" => Ok(false),
1242 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
1243 }
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248 #[test]
1250 fn resolve_settings_path_prefers_canonical() {
1251 let tmp = tempfile::tempdir().unwrap();
1252 let canonical = tmp.path().join("canonical");
1253 let legacy = tmp.path().join("legacy");
1254 std::fs::create_dir_all(&canonical).unwrap();
1255 std::fs::create_dir_all(&legacy).unwrap();
1256 std::fs::write(canonical.join("settings.toml"), "theme = \"x\"").unwrap();
1257 std::fs::write(legacy.join("settings.json"), "{}").unwrap();
1258
1259 let got = Settings::resolve_settings_path_in(&canonical, Some(&legacy), true);
1260 assert_eq!(got, canonical.join("settings.toml"));
1261 }
1262
1263 #[test]
1265 fn resolve_settings_path_falls_back_to_legacy() {
1266 let tmp = tempfile::tempdir().unwrap();
1267 let canonical = tmp.path().join("canonical");
1268 let legacy = tmp.path().join("legacy");
1269 std::fs::create_dir_all(&canonical).unwrap();
1270 std::fs::create_dir_all(&legacy).unwrap();
1271 std::fs::write(legacy.join("settings.json"), "{}").unwrap();
1272 std::fs::write(legacy.join("settings.toml"), "theme = \"x\"").unwrap();
1273
1274 let json = Settings::resolve_settings_path_in(&canonical, Some(&legacy), true);
1275 assert_eq!(json, legacy.join("settings.json"));
1276 let toml = Settings::resolve_settings_path_in(&canonical, Some(&legacy), false);
1277 assert_eq!(toml, legacy.join("settings.toml"));
1278 }
1279
1280 #[test]
1282 fn resolve_settings_path_defaults_to_canonical() {
1283 let tmp = tempfile::tempdir().unwrap();
1284 let canonical = tmp.path().join("canonical");
1285 std::fs::create_dir_all(&canonical).unwrap();
1286
1287 let got = Settings::resolve_settings_path_in(&canonical, None, true);
1288 assert_eq!(got, canonical.join("settings.json"));
1289 }
1290
1291 #[test]
1293 fn save_targets_canonical_dir() {
1294 let tmp = tempfile::tempdir().unwrap();
1298 let canonical = tmp.path().join("canonical");
1299 std::fs::create_dir_all(&canonical).unwrap();
1300 std::fs::write(canonical.join("settings.toml"), "theme = \"x\"").unwrap();
1301
1302 let json = Settings::resolve_settings_path_in(&canonical, None, true);
1303 assert_eq!(json, canonical.join("settings.toml"));
1304 }
1305
1306 #[test]
1309 fn inline_images_defaults_true_and_reads_override() {
1310 use super::*;
1311 assert!(Settings::default().inline_images, "previews on by default");
1312 let s: Settings = toml::from_str("inline_images = false").unwrap();
1313 assert!(!s.inline_images, "settings file can disable previews");
1314 }
1315
1316 use super::*;
1317 use std::io::Write as IoWrite;
1318 use std::sync::Mutex;
1319
1320 #[test]
1321 fn todo_settings_default_preserve_current_behavior() {
1322 let s = Settings::default();
1323 assert_eq!(s.todo_eager_mode, TodoEagerMode::Off);
1324 assert!(s.todo_reminders_enabled);
1325 assert_eq!(s.todo_reminders_max, 3);
1326 assert_eq!(s.todo_clear_delay_secs, 60);
1327 }
1328
1329 #[test]
1330 fn todo_eager_mode_round_trips_through_toml() {
1331 let parsed: TodoEagerMode = toml::from_str("v = \"always\"")
1332 .map(|t: toml::Value| TodoEagerMode::deserialize(t["v"].clone()).unwrap())
1333 .unwrap();
1334 assert_eq!(parsed, TodoEagerMode::Always);
1335 }
1336
1337 #[allow(dead_code)] static ENV_LOCK: Mutex<()> = Mutex::new(());
1340
1341 struct EnvGuard {
1344 saved: Vec<(String, Option<String>)>,
1345 }
1346
1347 impl EnvGuard {
1348 fn new(vars: &[&str]) -> Self {
1349 let saved = vars
1350 .iter()
1351 .map(|&name| {
1352 let old = env::var(name).ok();
1353 unsafe { env::remove_var(name) };
1355 (name.to_string(), old)
1356 })
1357 .collect();
1358 Self { saved }
1359 }
1360 }
1361
1362 impl Drop for EnvGuard {
1363 fn drop(&mut self) {
1364 for (name, old) in self.saved.drain(..) {
1365 match old {
1366 Some(val) => unsafe { env::set_var(&name, val) },
1368 None => unsafe { env::remove_var(&name) },
1369 }
1370 }
1371 }
1372 }
1373
1374 #[test]
1377 fn test_default_settings() {
1378 let settings = Settings::default();
1379 assert_eq!(settings.version, SETTINGS_VERSION);
1380 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1381 assert_eq!(settings.theme, "default");
1382 assert!(settings.last_used_model.is_none());
1383 assert!(settings.last_used_provider.is_none());
1384 assert!(settings.extensions_enabled);
1385 assert!(settings.auto_compaction);
1386 assert_eq!(settings.tool_timeout_seconds, 120);
1387 }
1388
1389 #[test]
1390 fn test_merge_cli() {
1391 let mut settings = Settings::default();
1392 settings.last_used_model = Some("gpt-4o".to_string());
1393
1394 settings.merge_cli(Some("claude".to_string()), None);
1395 assert_eq!(settings.last_used_model, Some("claude".to_string()));
1396
1397 settings.merge_cli(None, Some("google".to_string()));
1398 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1399 }
1400
1401 #[test]
1404 fn test_layer_file_overrides() {
1405 let base = Settings::default();
1406
1407 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1408 let toml_content = r#"
1409last_used_model = "openai/gpt-4o"
1410theme = "dracula"
1411"#;
1412 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1413
1414 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1415 assert_eq!(merged.last_used_model, Some("openai/gpt-4o".to_string()));
1416 assert_eq!(merged.theme, "dracula");
1417 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1419 assert!(merged.extensions_enabled);
1420 }
1421
1422 #[test]
1423 fn test_layer_file_preserves_unset() {
1424 let mut base = Settings::default();
1425 base.last_used_provider = Some("deepseek".to_string());
1426
1427 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1428 let toml_content = "theme = \"monokai\"\n";
1430 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1431
1432 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1433 assert_eq!(merged.theme, "monokai");
1434 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1435 }
1436
1437 #[test]
1438 fn test_load_from_dir_with_project_config() {
1439 let _guard = EnvGuard::new(&[
1440 "OXICODE_MODEL",
1441 "OXICODE_PROVIDER",
1442 "OXICODE_THEME",
1443 "OXICODE_TOOL_TIMEOUT",
1444 "OXICODE_TEMPERATURE",
1445 "OXICODE_MAX_TOKENS",
1446 "OXICODE_SESSION_DIR",
1447 "OXICODE_EXTENSIONS_ENABLED",
1448 ]);
1449 let tmp = tempfile::tempdir().unwrap();
1450 let oxicode_dir = tmp.path().join(".oxicode");
1451 fs::create_dir_all(&oxicode_dir).unwrap();
1452 let settings_path = oxicode_dir.join("settings.toml");
1453 fs::write(
1455 &settings_path,
1456 "version = 3\ndefault_model = \"google/gemini-2.0-flash\"\n",
1457 )
1458 .unwrap();
1459
1460 let settings = Settings::load_from(tmp.path()).unwrap();
1461 assert_eq!(
1463 settings.last_used_model,
1464 Some("gemini-2.0-flash".to_string())
1465 );
1466 assert_eq!(settings.last_used_provider, Some("google".to_string()));
1467 }
1468
1469 #[test]
1470 fn test_load_from_dir_no_config() {
1471 let _guard = EnvGuard::new(&[
1473 "OXICODE_MODEL",
1474 "OXICODE_PROVIDER",
1475 "OXICODE_THEME",
1476 "OXICODE_TOOL_TIMEOUT",
1477 "OXICODE_TEMPERATURE",
1478 "OXICODE_MAX_TOKENS",
1479 "OXICODE_SESSION_DIR",
1480 "OXICODE_EXTENSIONS_ENABLED",
1481 ]);
1482 let tmp = tempfile::tempdir().unwrap();
1483 let global = tmp.path().join("nonexistent-settings.json");
1488 let settings = Settings::load_from_with(tmp.path(), Some(&global)).unwrap();
1489 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1490 }
1491 #[test]
1492 fn test_from_env() {
1493 let _guard = EnvGuard::new(&[
1496 "OXICODE_MODEL",
1498 "OXICODE_THEME",
1499 "OXICODE_TOOL_TIMEOUT",
1500 "OXICODE_PROVIDER",
1501 "OXICODE_DEFAULT_MODEL",
1502 ]);
1503
1504 let settings = Settings::from_env();
1505 assert_eq!(settings.last_used_model, None);
1507 assert_eq!(settings.theme, "default");
1508 assert_eq!(settings.tool_timeout_seconds, 120);
1509 }
1510
1511 #[test]
1512 fn test_apply_env_boolish() {
1513 let _guard = EnvGuard::new(&["OXICODE_EXTENSIONS_ENABLED"]);
1516 unsafe { env::set_var("OXICODE_EXTENSIONS_ENABLED", "0") };
1517
1518 let mut settings = Settings::default();
1519 settings.apply_env();
1520 assert!(settings.extensions_enabled); }
1523
1524 #[test]
1525 fn test_apply_env_temperature() {
1526 let _guard = EnvGuard::new(&["OXICODE_TEMPERATURE"]);
1528 unsafe { env::set_var("OXICODE_TEMPERATURE", "0.7") };
1529
1530 let mut settings = Settings::default();
1531 settings.apply_env();
1532 assert_eq!(settings.default_temperature, None);
1534 }
1535
1536 #[test]
1537 fn test_env_does_not_override_when_unset() {
1538 let _guard = EnvGuard::new(&[
1539 "OXICODE_MODEL",
1540 "OXICODE_PROVIDER",
1541 "OXICODE_THEME",
1542 "OXICODE_TEMPERATURE",
1543 ]);
1544 let settings = Settings::from_env();
1545 assert!(settings.last_used_model.is_none());
1546 assert!(settings.last_used_provider.is_none());
1547 }
1548
1549 #[test]
1550 fn test_parse_thinking_level() {
1551 assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1552 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::Off));
1553 assert_eq!(
1554 parse_thinking_level("MINIMAL"),
1555 Some(ThinkingLevel::Minimal)
1556 );
1557 assert_eq!(parse_thinking_level("Low"), Some(ThinkingLevel::Low));
1558 assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1559 assert_eq!(parse_thinking_level("Medium"), Some(ThinkingLevel::Medium));
1560 assert_eq!(
1561 parse_thinking_level("Standard"),
1562 Some(ThinkingLevel::Medium)
1563 );
1564 assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1565 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::High));
1566 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1567 assert_eq!(parse_thinking_level("invalid"), None);
1568 }
1569
1570 #[test]
1571 fn test_parse_boolish() {
1572 assert!(parse_boolish("true").unwrap());
1573 assert!(parse_boolish("1").unwrap());
1574 assert!(parse_boolish("yes").unwrap());
1575 assert!(parse_boolish("ON").unwrap());
1576 assert!(!parse_boolish("false").unwrap());
1577 assert!(!parse_boolish("0").unwrap());
1578 assert!(!parse_boolish("no").unwrap());
1579 assert!(!parse_boolish("OFF").unwrap());
1580 assert!(parse_boolish("maybe").is_err());
1581 }
1582
1583 #[test]
1586 fn test_effective_model_returns_last_used() {
1587 let mut settings = Settings::default();
1588 settings.last_used_model = Some("openai/gpt-4o".to_string());
1589 assert_eq!(
1590 settings.effective_model(None),
1591 Some("openai/gpt-4o".to_string())
1592 );
1593 }
1594
1595 #[test]
1596 fn test_effective_model_cli_overrides() {
1597 let mut settings = Settings::default();
1598 settings.last_used_model = Some("openai/gpt-4o".to_string());
1599 assert_eq!(
1600 settings.effective_model(Some("anthropic/claude-3")),
1601 Some("anthropic/claude-3".to_string())
1602 );
1603 }
1604
1605 #[test]
1606 fn test_effective_model_none_when_unset() {
1607 let settings = Settings::default();
1608 assert_eq!(settings.effective_model(None), None);
1609 }
1610
1611 #[test]
1612 fn test_effective_model_falls_back_to_last_used() {
1613 let mut settings = Settings::default();
1614 settings.last_used_model = Some("anthropic/claude-3".to_string());
1615 assert_eq!(
1616 settings.effective_model(None),
1617 Some("anthropic/claude-3".to_string())
1618 );
1619 }
1620
1621 #[test]
1622 fn test_effective_model_returns_none_when_nothing_set() {
1623 let settings = Settings::default();
1624 assert_eq!(settings.effective_model(None), None);
1625 }
1626
1627 #[test]
1628 fn test_effective_temperature_prefers_f64() {
1629 let mut settings = Settings::default();
1630 settings.temperature = Some(0.5);
1631 settings.default_temperature = Some(0.7);
1632 assert_eq!(settings.effective_temperature(), Some(0.7));
1633 }
1634
1635 #[test]
1636 fn test_effective_temperature_falls_back_to_f32() {
1637 let mut settings = Settings::default();
1638 settings.temperature = Some(0.5);
1639 assert_eq!(settings.effective_temperature(), Some(0.5));
1640 }
1641
1642 #[test]
1643 fn test_effective_max_tokens_prefers_usize() {
1644 let mut settings = Settings::default();
1645 settings.max_tokens = Some(1024);
1646 settings.max_response_tokens = Some(4096);
1647 assert_eq!(settings.effective_max_tokens(), Some(4096));
1648 }
1649
1650 #[test]
1651 fn test_effective_max_tokens_falls_back_to_u32() {
1652 let mut settings = Settings::default();
1653 settings.max_tokens = Some(1024);
1654 assert_eq!(settings.effective_max_tokens(), Some(1024));
1655 }
1656
1657 #[test]
1660 fn test_effective_session_dir_default() {
1661 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1662 let settings = Settings::default();
1663 let dir = settings.effective_session_dir().unwrap();
1664 assert!(dir.ends_with("sessions"), "dir was: {:?}", dir);
1665 }
1666
1667 #[test]
1668 fn test_effective_session_dir_from_field() {
1669 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1670 let mut settings = Settings::default();
1671 settings.session_dir = Some(PathBuf::from("/tmp/oxicode-sessions"));
1672 assert_eq!(
1673 settings.effective_session_dir().unwrap(),
1674 PathBuf::from("/tmp/oxicode-sessions")
1675 );
1676 }
1677
1678 #[test]
1679 fn test_effective_session_dir_env_disabled() {
1680 let _guard = EnvGuard::new(&["OXICODE_SESSION_DIR"]);
1683 unsafe { env::set_var("OXICODE_SESSION_DIR", "/tmp/env-sessions") };
1684 let settings = Settings::default();
1685 let dir = settings.effective_session_dir().unwrap();
1687 assert!(
1688 dir.ends_with("sessions"),
1689 "expected default sessions dir, got: {:?}",
1690 dir
1691 );
1692 }
1693
1694 #[test]
1697 fn test_migration_v0_to_v1() {
1698 let mut settings = Settings::default();
1699 settings.version = 0;
1700 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
1703 assert_eq!(migrated.version, SETTINGS_VERSION);
1704 assert_eq!(migrated.tool_timeout_seconds, 120);
1705 }
1706
1707 #[test]
1708 fn test_migration_already_current() {
1709 let settings = Settings::default();
1710 let migrated = Settings::migrate(settings).unwrap();
1711 assert_eq!(migrated.version, SETTINGS_VERSION);
1712 }
1713
1714 #[test]
1715 fn test_migration_v3_to_v4_splits_model() {
1716 let mut settings = Settings::default();
1717 settings.version = 3;
1718 settings.default_model = Some("openai/gpt-4o".to_string());
1719 settings.default_provider = None;
1720
1721 let migrated = Settings::migrate(settings).unwrap();
1722 assert_eq!(migrated.version, SETTINGS_VERSION);
1723 assert_eq!(migrated.last_used_model, Some("gpt-4o".to_string()));
1724 assert_eq!(migrated.last_used_provider, Some("openai".to_string()));
1725 }
1726
1727 #[test]
1728 fn test_migration_v3_no_slash_keeps_model() {
1729 let mut settings = Settings::default();
1730 settings.version = 3;
1731 settings.default_model = Some("bare-model-name".to_string());
1732
1733 let migrated = Settings::migrate(settings).unwrap();
1734 assert_eq!(migrated.version, SETTINGS_VERSION);
1735 assert_eq!(
1736 migrated.last_used_model,
1737 Some("bare-model-name".to_string())
1738 );
1739 }
1740
1741 #[test]
1742 fn test_migration_future_version_fails() {
1743 let mut settings = Settings::default();
1744 settings.version = 9999;
1745 assert!(Settings::migrate(settings).is_err());
1746 }
1747
1748 #[test]
1749 fn test_default_glyph_set_is_unicode() {
1750 let settings = Settings::default();
1751 assert_eq!(
1752 settings.glyph_set,
1753 GlyphSet::Unicode,
1754 "glyph_set must default to Unicode"
1755 );
1756 }
1757
1758 #[test]
1759 fn test_migration_v7_to_v8_defaults_glyph_set_to_unicode() {
1760 let mut settings = Settings::default();
1763 settings.version = 7;
1764 settings.glyph_set = GlyphSet::default();
1766
1767 let migrated = Settings::migrate(settings).unwrap();
1768 assert_eq!(migrated.version, SETTINGS_VERSION);
1769 assert_eq!(
1770 migrated.glyph_set,
1771 GlyphSet::Unicode,
1772 "v7 → v8 migration must default glyph_set to unicode"
1773 );
1774 }
1775
1776 #[test]
1777 fn test_glyph_set_persists_through_roundtrip() {
1778 let mut original = Settings::default();
1782 original.glyph_set = GlyphSet::Nerd;
1783 let content = toml::to_string_pretty(&original).unwrap();
1784 assert!(
1785 content.contains("glyph_set = \"nerd\""),
1786 "nerd preset must serialize to snake_case; got:\n{content}"
1787 );
1788 let loaded: Settings = toml::from_str(&content).unwrap();
1789 assert_eq!(loaded.glyph_set, GlyphSet::Nerd);
1790 original.glyph_set = GlyphSet::Unicode;
1792 let uni: Settings = toml::from_str(&toml::to_string_pretty(&original).unwrap()).unwrap();
1793 assert_eq!(uni.glyph_set, GlyphSet::Unicode);
1794 }
1795
1796 #[test]
1797 fn test_save_and_load_roundtrip() {
1798 let tmp = tempfile::tempdir().unwrap();
1799 let settings_path = tmp.path().join("settings.toml");
1800
1801 let mut original = Settings::default();
1802 original.last_used_model = Some("gpt-4o".to_string());
1803 original.last_used_provider = Some("openai".to_string());
1804 original.theme = "dracula".to_string();
1805 original.tool_timeout_seconds = 60;
1806
1807 let content = toml::to_string_pretty(&original).unwrap();
1809 fs::write(&settings_path, &content).unwrap();
1810
1811 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1813 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1814
1815 assert_eq!(loaded.last_used_model, original.last_used_model);
1816 assert_eq!(loaded.theme, original.theme);
1817 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1818 }
1819
1820 #[test]
1821 fn test_toml_roundtrip_preserves_new_fields() {
1822 let mut settings = Settings::default();
1823 settings.default_temperature = Some(0.8);
1824 settings.max_response_tokens = Some(8192);
1825 settings.auto_compaction = false;
1826 settings.extensions_enabled = false;
1827 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1828
1829 let toml_str = toml::to_string_pretty(&settings).unwrap();
1830 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1831
1832 assert_eq!(parsed.default_temperature, Some(0.8));
1833 assert_eq!(parsed.max_response_tokens, Some(8192));
1834 assert!(!parsed.auto_compaction);
1835 assert!(!parsed.extensions_enabled);
1836 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1837 }
1838
1839 #[test]
1842 fn test_json_roundtrip() {
1843 let mut settings = Settings::default();
1844 settings.last_used_model = Some("gpt-4o".to_string());
1845 settings.last_used_provider = Some("openai".to_string());
1846 settings.theme = "dracula".to_string();
1847 settings.tool_timeout_seconds = 60;
1848 settings.default_temperature = Some(0.8);
1849 settings.max_response_tokens = Some(8192);
1850
1851 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1852 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1853
1854 assert_eq!(parsed.last_used_model, settings.last_used_model);
1855 assert_eq!(parsed.theme, settings.theme);
1856 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1857 assert_eq!(parsed.default_temperature, settings.default_temperature);
1858 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1859 }
1860
1861 #[test]
1862 fn test_json_serialize_for_format() {
1863 let mut settings = Settings::default();
1864 settings.last_used_model = Some("claude-3".to_string());
1865 settings.last_used_provider = Some("anthropic".to_string());
1866 settings.thinking_level = ThinkingLevel::Minimal;
1867
1868 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1869 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1870
1871 assert_eq!(parsed.last_used_model, Some("claude-3".to_string()));
1872 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1873 }
1874
1875 #[test]
1876 fn test_toml_serialize_for_format() {
1877 let mut settings = Settings::default();
1878 settings.last_used_model = Some("gemini-pro".to_string());
1879 settings.last_used_provider = Some("google".to_string());
1880 settings.thinking_level = ThinkingLevel::High;
1881
1882 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1883 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1884
1885 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
1886 assert_eq!(parsed.thinking_level, ThinkingLevel::High);
1887 }
1888
1889 #[test]
1890 fn test_parse_from_str_json() {
1891 let json_content = r#"{
1892 "last_used_model": "gpt-4",
1893 "last_used_provider": "openai",
1894 "theme": "nord",
1895 "tool_timeout_seconds": 90
1896 }"#;
1897
1898 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1899 assert_eq!(settings.last_used_model, Some("gpt-4".to_string()));
1900 assert_eq!(settings.last_used_provider, Some("openai".to_string()));
1901 assert_eq!(settings.theme, "nord");
1902 assert_eq!(settings.tool_timeout_seconds, 90);
1903 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1905 assert!(settings.extensions_enabled);
1906 }
1907
1908 #[test]
1909 fn test_parse_from_str_toml() {
1910 let toml_content = r#"
1911last_used_model = "claude-opus"
1912last_used_provider = "anthropic"
1913theme = "monokai"
1914tool_timeout_seconds = 45
1915"#;
1916
1917 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1918 assert_eq!(settings.last_used_model, Some("claude-opus".to_string()));
1919 assert_eq!(settings.last_used_provider, Some("anthropic".to_string()));
1920 assert_eq!(settings.theme, "monokai");
1921 assert_eq!(settings.tool_timeout_seconds, 45);
1922 assert_eq!(settings.thinking_level, ThinkingLevel::Medium);
1923 }
1924
1925 #[test]
1926 fn test_layer_file_json() {
1927 let base = Settings::default();
1928
1929 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1930 let json_content = r#"{
1931 "last_used_model": "gpt-4o",
1932 "last_used_provider": "openai",
1933 "theme": "dracula",
1934 "auto_compaction": false
1935 }"#;
1936 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1937
1938 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1939 assert_eq!(merged.last_used_model, Some("gpt-4o".to_string()));
1940 assert_eq!(merged.last_used_provider, Some("openai".to_string()));
1941 assert_eq!(merged.theme, "dracula");
1942 assert!(!merged.auto_compaction);
1943 assert_eq!(merged.thinking_level, ThinkingLevel::Medium);
1945 assert!(merged.extensions_enabled);
1946 assert_eq!(merged.tool_timeout_seconds, 120);
1947 }
1948
1949 #[test]
1950 fn test_layer_file_json_preserves_unset() {
1951 let mut base = Settings::default();
1952 base.last_used_provider = Some("deepseek".to_string());
1953
1954 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1955 let json_content = r#"{ "theme": "nord" }"#;
1956 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1957
1958 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1959 assert_eq!(merged.theme, "nord");
1960 assert_eq!(merged.last_used_provider, Some("deepseek".to_string()));
1961 }
1962
1963 #[test]
1964 fn test_save_to_json() {
1965 let tmp = tempfile::tempdir().unwrap();
1966 let settings_path = tmp.path().join("settings.json");
1967
1968 let mut settings = Settings::default();
1969 settings.last_used_model = Some("gpt-4o".to_string());
1970 settings.last_used_provider = Some("openai".to_string());
1971 settings.theme = "dracula".to_string();
1972 settings.tool_timeout_seconds = 60;
1973
1974 settings.save_to(&settings_path).unwrap();
1975
1976 let content = fs::read_to_string(&settings_path).unwrap();
1978 let parsed: Settings = serde_json::from_str(&content).unwrap();
1979 assert_eq!(parsed.last_used_model, Some("gpt-4o".to_string()));
1980 assert_eq!(parsed.theme, "dracula");
1981 assert_eq!(parsed.tool_timeout_seconds, 60);
1982 }
1983
1984 #[test]
1985 fn test_save_to_toml() {
1986 let tmp = tempfile::tempdir().unwrap();
1987 let settings_path = tmp.path().join("settings.toml");
1988
1989 let mut settings = Settings::default();
1990 settings.last_used_model = Some("gemini-pro".to_string());
1991 settings.last_used_provider = Some("google".to_string());
1992 settings.theme = "monokai".to_string();
1993 settings.tool_timeout_seconds = 90;
1994
1995 settings.save_to(&settings_path).unwrap();
1996
1997 let content = fs::read_to_string(&settings_path).unwrap();
1999 let parsed: Settings = toml::from_str(&content).unwrap();
2000 assert_eq!(parsed.last_used_model, Some("gemini-pro".to_string()));
2001 assert_eq!(parsed.theme, "monokai");
2002 assert_eq!(parsed.tool_timeout_seconds, 90);
2003 }
2004
2005 #[test]
2006 fn test_load_from_dir_with_json_project_config() {
2007 let _guard = EnvGuard::new(&[
2008 "OXICODE_MODEL",
2009 "OXICODE_PROVIDER",
2010 "OXICODE_THEME",
2011 "OXICODE_TOOL_TIMEOUT",
2012 "OXICODE_TEMPERATURE",
2013 "OXICODE_MAX_TOKENS",
2014 "OXICODE_SESSION_DIR",
2015 "OXICODE_EXTENSIONS_ENABLED",
2016 ]);
2017 let tmp = tempfile::tempdir().unwrap();
2018 let oxicode_dir = tmp.path().join(".oxicode");
2019 fs::create_dir_all(&oxicode_dir).unwrap();
2020 let settings_path = oxicode_dir.join("settings.json");
2021 let json_content = r#"{ "version": 3, "default_model": "google/gemini-2.0-flash" }"#;
2023 fs::write(&settings_path, json_content).unwrap();
2024
2025 let settings = Settings::load_from(tmp.path()).unwrap();
2026 assert_eq!(
2028 settings.last_used_model,
2029 Some("gemini-2.0-flash".to_string())
2030 );
2031 assert_eq!(settings.last_used_provider, Some("google".to_string()));
2032 }
2033
2034 #[test]
2035 fn test_find_project_settings_json_priority() {
2036 let tmp = tempfile::tempdir().unwrap();
2037 let oxicode_dir = tmp.path().join(".oxicode");
2038 fs::create_dir_all(&oxicode_dir).unwrap();
2039
2040 let json_path = oxicode_dir.join("settings.json");
2042 let toml_path = oxicode_dir.join("settings.toml");
2043 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
2044 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
2045
2046 let found = Settings::find_project_settings(tmp.path());
2048 assert!(found.is_some());
2049 assert_eq!(
2050 found.unwrap().file_name().unwrap().to_str().unwrap(),
2051 "settings.json"
2052 );
2053 }
2054
2055 #[test]
2056 fn test_find_project_settings_json_only() {
2057 let tmp = tempfile::tempdir().unwrap();
2058 let oxicode_dir = tmp.path().join(".oxicode");
2059 fs::create_dir_all(&oxicode_dir).unwrap();
2060
2061 let json_path = oxicode_dir.join("settings.json");
2062 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
2063
2064 let found = Settings::find_project_settings(tmp.path());
2065 assert!(found.is_some());
2066 assert_eq!(
2067 found.unwrap().file_name().unwrap().to_str().unwrap(),
2068 "settings.json"
2069 );
2070 }
2071
2072 #[test]
2073 fn test_find_project_settings_toml_fallback() {
2074 let tmp = tempfile::tempdir().unwrap();
2075 let oxicode_dir = tmp.path().join(".oxicode");
2076 fs::create_dir_all(&oxicode_dir).unwrap();
2077
2078 let toml_path = oxicode_dir.join("settings.toml");
2079 fs::write(&toml_path, r#"theme = "test""#).unwrap();
2080
2081 let found = Settings::find_project_settings(tmp.path());
2082 assert!(found.is_some());
2083 assert_eq!(
2084 found.unwrap().file_name().unwrap().to_str().unwrap(),
2085 "settings.toml"
2086 );
2087 }
2088
2089 #[test]
2090 fn test_detect_format() {
2091 let json_path = PathBuf::from("/test/settings.json");
2092 let toml_path = PathBuf::from("/test/settings.toml");
2093 let unknown_path = PathBuf::from("/test/settings");
2094
2095 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
2096 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
2097 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
2098 }
2100
2101 #[test]
2102 fn test_settings_format_extension() {
2103 assert_eq!(SettingsFormat::Json.extension(), "json");
2104 assert_eq!(SettingsFormat::Toml.extension(), "toml");
2105 }
2106
2107 #[test]
2108 fn test_layer_json_over_toml() {
2109 let tmp = tempfile::tempdir().unwrap();
2111 let oxicode_dir = tmp.path().join(".oxicode");
2112 fs::create_dir_all(&oxicode_dir).unwrap();
2113
2114 let json_path = oxicode_dir.join("settings.json");
2115 let toml_path = oxicode_dir.join("settings.toml");
2116
2117 fs::write(&json_path, r#"{ "last_used_model": "json-model" }"#).unwrap();
2119 fs::write(&toml_path, r#"last_used_model = "toml-model""#).unwrap();
2121
2122 let settings = Settings::load_from(tmp.path()).unwrap();
2124 assert_eq!(settings.last_used_model, Some("json-model".to_string()));
2125 }
2126
2127 #[test]
2128 fn test_mixed_format_loading() {
2129 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2131 let toml_content = r#"
2132last_used_model = "loaded-via-toml"
2133theme = "loaded-theme"
2134"#;
2135 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2136
2137 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
2138 assert_eq!(merged.last_used_model, Some("loaded-via-toml".to_string()));
2139 assert_eq!(merged.theme, "loaded-theme");
2140 }
2141
2142 #[test]
2143 fn test_merge_json_values() {
2144 let base = serde_json::json!({
2145 "version": 1,
2146 "theme": "default",
2147 "extensions": ["ext1"],
2148 "nested": {
2149 "a": 1,
2150 "b": 2
2151 }
2152 });
2153
2154 let override_ = serde_json::json!({
2155 "version": 2,
2156 "theme": "dark",
2157 "extensions": ["ext2"],
2158 "nested": {
2159 "b": 20,
2160 "c": 30
2161 }
2162 });
2163
2164 let merged = merge_json_values(base, override_);
2165
2166 assert_eq!(merged["version"], 2);
2167 assert_eq!(merged["theme"], "dark");
2168 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
2170 assert_eq!(merged["nested"]["a"], 1);
2172 assert_eq!(merged["nested"]["b"], 20);
2173 assert_eq!(merged["nested"]["c"], 30);
2174 }
2175
2176 #[test]
2177 fn test_save_project_preserves_existing_format() {
2178 let tmp = tempfile::tempdir().unwrap();
2179 let oxicode_dir = tmp.path().join(".oxicode");
2180 fs::create_dir_all(&oxicode_dir).unwrap();
2181
2182 let toml_path = oxicode_dir.join("settings.toml");
2184 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
2185
2186 let mut settings = Settings::default();
2187 settings.theme = "new-theme".to_string();
2188 settings.save_project(tmp.path()).unwrap();
2189
2190 let content = fs::read_to_string(&toml_path).unwrap();
2192 assert!(content.contains("new-theme"));
2193 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
2194 }
2195
2196 #[test]
2197 fn test_save_project_creates_json_by_default() {
2198 let tmp = tempfile::tempdir().unwrap();
2199 let oxicode_dir = tmp.path().join(".oxicode");
2200 fs::create_dir_all(&oxicode_dir).unwrap();
2201 let mut settings = Settings::default();
2204 settings.theme = "json-theme".to_string();
2205 settings.save_project(tmp.path()).unwrap();
2206
2207 let json_path = oxicode_dir.join("settings.json");
2209 assert!(json_path.exists());
2210 let content = fs::read_to_string(&json_path).unwrap();
2211 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
2212 assert!(content.contains("json-theme"));
2213 }
2214
2215 #[test]
2218 fn test_custom_provider_default_api() {
2219 use super::CustomProvider;
2220 let cp = CustomProvider {
2221 name: "test".to_string(),
2222 base_url: "https://api.test.com/v1".to_string(),
2223 api_key_env: "TEST_API_KEY".to_string(),
2224 api: super::default_custom_provider_api(),
2225 };
2226 assert_eq!(cp.api, "openai-completions");
2227 }
2228
2229 #[test]
2230 fn test_custom_provider_toml_deserialize() {
2231 let toml_content = r#"
2232[[custom_providers]]
2233name = "minimax"
2234base_url = "https://api.minimax.chat/v1"
2235api_key_env = "MINIMAX_API_KEY"
2236api = "openai-completions"
2237
2238[[custom_providers]]
2239name = "zai"
2240base_url = "https://api.z.ai/v1"
2241api_key_env = "ZAI_API_KEY"
2242api = "openai-responses"
2243"#;
2244 let settings: Settings = toml::from_str(toml_content).unwrap();
2245 assert_eq!(settings.custom_providers.len(), 2);
2246 assert_eq!(settings.custom_providers[0].name, "minimax");
2247 assert_eq!(
2248 settings.custom_providers[0].base_url,
2249 "https://api.minimax.chat/v1"
2250 );
2251 assert_eq!(settings.custom_providers[0].api_key_env, "MINIMAX_API_KEY");
2252 assert_eq!(settings.custom_providers[0].api, "openai-completions");
2253 assert_eq!(settings.custom_providers[1].name, "zai");
2254 assert_eq!(settings.custom_providers[1].api, "openai-responses");
2255 }
2256
2257 #[test]
2258 fn test_custom_provider_json_deserialize() {
2259 let json_content = r#"{
2260 "custom_providers": [
2261 {
2262 "name": "minimax",
2263 "base_url": "https://api.minimax.chat/v1",
2264 "api_key_env": "MINIMAX_API_KEY",
2265 "api": "openai-completions"
2266 }
2267 ]
2268 }"#;
2269 let settings: Settings = serde_json::from_str(json_content).unwrap();
2270 assert_eq!(settings.custom_providers.len(), 1);
2271 assert_eq!(settings.custom_providers[0].name, "minimax");
2272 }
2273
2274 #[test]
2275 fn test_custom_provider_toml_roundtrip() {
2276 let mut settings = Settings::default();
2277 settings.custom_providers.push(super::CustomProvider {
2278 name: "test".to_string(),
2279 base_url: "https://api.test.com/v1".to_string(),
2280 api_key_env: "TEST_API_KEY".to_string(),
2281 api: "openai-completions".to_string(),
2282 });
2283
2284 let toml_str = toml::to_string_pretty(&settings).unwrap();
2285 let parsed: Settings = toml::from_str(&toml_str).unwrap();
2286 assert_eq!(parsed.custom_providers.len(), 1);
2287 assert_eq!(parsed.custom_providers[0].name, "test");
2288 assert_eq!(
2289 parsed.custom_providers[0].base_url,
2290 "https://api.test.com/v1"
2291 );
2292 }
2293
2294 #[test]
2295 fn test_custom_provider_defaults_empty() {
2296 let settings = Settings::default();
2297 assert!(settings.custom_providers.is_empty());
2298 }
2299
2300 #[test]
2301 fn test_custom_provider_layer_file() {
2302 let base = Settings::default();
2303
2304 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
2305 let toml_content = r#"
2306[[custom_providers]]
2307name = "my-provider"
2308base_url = "https://api.my-provider.com/v1"
2309api_key_env = "MY_PROVIDER_API_KEY"
2310"#;
2311 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
2312
2313 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
2314 assert_eq!(merged.custom_providers.len(), 1);
2315 assert_eq!(merged.custom_providers[0].name, "my-provider");
2316 assert_eq!(merged.custom_providers[0].api, "openai-completions");
2318 }
2319
2320 #[test]
2321 fn settings_deserialise_hooks_array() {
2322 let toml = r#"
2323 [[hooks]]
2324 event = "PreToolUse"
2325 matcher = "bash|write"
2326 command = "echo pre"
2327 timeout_secs = 10
2328 "#;
2329 let s: Settings = toml::from_str(toml).unwrap();
2330 assert_eq!(s.hooks.len(), 1);
2331 assert_eq!(s.hooks[0].event, oxicode_sdk::ports::HookEvent::PreToolUse);
2332 assert_eq!(s.hooks[0].matcher.as_deref(), Some("bash|write"));
2333 assert_eq!(s.hooks[0].command, "echo pre");
2334 assert_eq!(s.hooks[0].timeout_secs, Some(10));
2335 }
2336
2337 #[test]
2338 fn settings_default_has_no_hooks() {
2339 let s = Settings::default();
2340 assert!(s.hooks.is_empty());
2341 }
2342}