1use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::env;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18const SETTINGS_VERSION: u32 = 2;
20
21#[allow(dead_code)]
23const ENV_PREFIX: &str = "OXI_";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum ThinkingLevel {
29 None,
31 Minimal,
33 #[default]
35 Standard,
36 Thorough,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Settings {
43 #[serde(default)]
46 pub version: u32,
47
48 #[serde(default)]
51 pub thinking_level: ThinkingLevel,
52
53 #[serde(default = "default_theme")]
55 pub theme: String,
56
57 pub default_model: Option<String>,
59
60 pub default_provider: Option<String>,
62
63 pub max_tokens: Option<u32>,
65
66 pub temperature: Option<f32>,
68
69 pub default_temperature: Option<f64>,
71
72 pub max_response_tokens: Option<usize>,
74
75 #[serde(default = "default_session_history_size")]
78 pub session_history_size: usize,
79
80 pub session_dir: Option<PathBuf>,
82
83 #[serde(default = "default_true")]
86 pub stream_responses: bool,
87
88 #[serde(default = "default_true")]
90 pub extensions_enabled: bool,
91
92 #[serde(default = "default_true")]
94 pub auto_compaction: bool,
95
96 #[serde(default = "default_tool_timeout")]
99 pub tool_timeout_seconds: u64,
100
101 #[serde(default)]
104 pub extensions: Vec<String>,
105
106 #[serde(default)]
108 pub skills: Vec<String>,
109
110 #[serde(default)]
112 pub prompts: Vec<String>,
113
114 #[serde(default)]
116 pub themes: Vec<String>,
117}
118
119fn default_theme() -> String {
120 "default".to_string()
121}
122
123fn default_session_history_size() -> usize {
124 100
125}
126
127fn default_true() -> bool {
128 true
129}
130
131fn default_tool_timeout() -> u64 {
132 120
133}
134
135impl Default for Settings {
136 fn default() -> Self {
137 Self {
138 version: SETTINGS_VERSION,
139 thinking_level: ThinkingLevel::Standard,
140 theme: default_theme(),
141 default_model: None,
142 default_provider: None,
143 max_tokens: None,
144 temperature: None,
145 default_temperature: None,
146 max_response_tokens: None,
147 session_history_size: default_session_history_size(),
148 session_dir: None,
149 stream_responses: true,
150 extensions_enabled: true,
151 auto_compaction: true,
152 tool_timeout_seconds: default_tool_timeout(),
153 extensions: Vec::new(),
154 skills: Vec::new(),
155 prompts: Vec::new(),
156 themes: Vec::new(),
157 }
158 }
159}
160
161impl Settings {
162 pub fn settings_dir() -> Result<PathBuf> {
166 let base = dirs::home_dir().context("Cannot determine home directory")?;
167 Ok(base.join(".oxi"))
168 }
169
170 pub fn settings_toml_path() -> Result<PathBuf> {
172 Ok(Self::settings_dir()?.join("settings.toml"))
173 }
174
175 pub fn settings_json_path() -> Result<PathBuf> {
177 Ok(Self::settings_dir()?.join("settings.json"))
178 }
179
180 pub fn settings_path() -> Result<PathBuf> {
187 let json_path = Self::settings_json_path()?;
188 let toml_path = Self::settings_toml_path()?;
189
190 if json_path.exists() && toml_path.exists() {
191 tracing::debug!(
193 "Both settings.json and settings.toml exist, using settings.json"
194 );
195 return Ok(json_path);
196 }
197
198 if json_path.exists() {
199 return Ok(json_path);
200 }
201
202 if toml_path.exists() {
203 return Ok(toml_path);
204 }
205
206 Ok(json_path)
208 }
209
210 pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
215 let json_path = Self::settings_json_path()?;
216 let toml_path = Self::settings_toml_path()?;
217
218 let (primary, secondary) = if prefer_json {
219 (&json_path, &toml_path)
220 } else {
221 (&toml_path, &json_path)
222 };
223
224 if primary.exists() {
225 return Ok(primary.clone());
226 }
227
228 if secondary.exists() {
229 return Ok(secondary.clone());
230 }
231
232 Ok(primary.clone())
234 }
235
236 pub fn detect_format(path: &Path) -> SettingsFormat {
238 match path.extension().and_then(|e| e.to_str()) {
239 Some("json") => SettingsFormat::Json,
240 Some("toml") => SettingsFormat::Toml,
241 _ => SettingsFormat::Json, }
243 }
244
245 pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
250 let mut dir = start_dir.to_path_buf();
251 loop {
252 let json_candidate = dir.join(".oxi").join("settings.json");
254 if json_candidate.exists() {
255 return Some(json_candidate);
256 }
257
258 let toml_candidate = dir.join(".oxi").join("settings.toml");
259 if toml_candidate.exists() {
260 return Some(toml_candidate);
261 }
262
263 if !dir.pop() {
264 return None;
265 }
266 }
267 }
268
269 pub fn effective_session_dir(&self) -> Result<PathBuf> {
273 if let Some(ref dir) = self.session_dir {
274 return Ok(dir.clone());
275 }
276 if let Ok(dir) = env::var("OXI_SESSION_DIR") {
277 return Ok(PathBuf::from(dir));
278 }
279 Ok(Self::settings_dir()?.join("sessions"))
280 }
281
282 pub fn load() -> Result<Self> {
291 Self::load_from_cwd()
292 }
293
294 pub fn load_from(dir: &std::path::Path) -> Result<Self> {
296 let mut settings = Settings::default();
298
299 if let Ok(global_path) = Self::settings_path() {
301 if global_path.exists() {
302 settings = Self::layer_file(&settings, &global_path)?;
303 }
304 }
305
306 if let Some(project_path) = Self::find_project_settings(dir) {
308 settings = Self::layer_file(&settings, &project_path)?;
309 }
310
311 settings.apply_env();
313
314 settings = Self::migrate(settings)?;
316
317 Ok(settings)
318 }
319
320 pub fn load_from_cwd() -> Result<Self> {
322 let cwd = env::current_dir().context("Cannot determine current directory")?;
323 Self::load_from(&cwd)
324 }
325
326 fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
332 let content = fs::read_to_string(path)
333 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
334
335 let format = Self::detect_format(path);
336 let overlay: serde_json::Value = match format {
337 SettingsFormat::Toml => {
338 let toml_value: toml::Value = toml::from_str(&content)
339 .with_context(|| format!("Failed to parse TOML settings from {}", path.display()))?;
340 toml_value_to_json(toml_value)
342 }
343 SettingsFormat::Json => {
344 serde_json::from_str(&content)
345 .with_context(|| format!("Failed to parse JSON settings from {}", path.display()))?
346 }
347 };
348
349 let base_json = serde_json::to_value(base)
353 .context("Failed to serialize base settings for merge")?;
354
355 let merged = merge_json_values(base_json, overlay);
356 let result: Settings = serde_json::from_value(merged)
357 .context("Failed to deserialize merged settings")?;
358
359 Ok(result)
360 }
361
362 pub fn apply_env(&mut self) {
382 if let Ok(v) = env::var("OXI_MODEL") {
383 self.default_model = Some(v);
384 }
385 if let Ok(v) = env::var("OXI_PROVIDER") {
386 self.default_provider = Some(v);
387 }
388 if let Ok(v) = env::var("OXI_THINKING") {
389 if let Some(level) = parse_thinking_level(&v) {
390 self.thinking_level = level;
391 }
392 }
393 if let Ok(v) = env::var("OXI_THEME") {
394 self.theme = v;
395 }
396 if let Ok(v) = env::var("OXI_MAX_TOKENS") {
397 if let Ok(n) = v.parse::<u32>() {
398 self.max_tokens = Some(n);
399 }
400 }
401 if let Ok(v) = env::var("OXI_TEMPERATURE") {
402 if let Ok(n) = v.parse::<f64>() {
403 self.default_temperature = Some(n);
404 }
405 }
406 if let Ok(v) = env::var("OXI_SESSION_DIR") {
407 self.session_dir = Some(PathBuf::from(v));
408 }
409 if let Ok(v) = env::var("OXI_STREAM") {
410 if let Ok(b) = parse_boolish(&v) {
411 self.stream_responses = b;
412 }
413 }
414 if let Ok(v) = env::var("OXI_EXTENSIONS_ENABLED") {
415 if let Ok(b) = parse_boolish(&v) {
416 self.extensions_enabled = b;
417 }
418 }
419 if let Ok(v) = env::var("OXI_AUTO_COMPACTION") {
420 if let Ok(b) = parse_boolish(&v) {
421 self.auto_compaction = b;
422 }
423 }
424 if let Ok(v) = env::var("OXI_TOOL_TIMEOUT") {
425 if let Ok(n) = v.parse::<u64>() {
426 self.tool_timeout_seconds = n;
427 }
428 }
429 }
430
431 pub fn from_env() -> Self {
434 let mut settings = Self::default();
435 settings.apply_env();
436 settings
437 }
438
439 pub fn save(&self) -> Result<()> {
446 let dir = Self::settings_dir()?;
447 let path = Self::settings_path()?;
448
449 if !dir.exists() {
450 fs::create_dir_all(&dir)
451 .with_context(|| format!("Failed to create settings directory {}", dir.display()))?;
452 }
453
454 let format = Self::detect_format(&path);
455 let content = Self::serialize_for_format(self, format)?;
456
457 let tmp_path = path.with_extension("tmp");
459 fs::write(&tmp_path, &content)
460 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
461 fs::rename(&tmp_path, &path)
462 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
463
464 Ok(())
465 }
466
467 pub fn save_to(&self, path: &Path) -> Result<()> {
469 if let Some(parent) = path.parent() {
470 if !parent.exists() {
471 fs::create_dir_all(parent)
472 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
473 }
474 }
475
476 let format = Self::detect_format(path);
477 let content = Self::serialize_for_format(self, format)?;
478
479 let tmp_path = path.with_extension("tmp");
481 fs::write(&tmp_path, &content)
482 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
483 fs::rename(&tmp_path, path)
484 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
485
486 Ok(())
487 }
488
489 pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
493 let dir = project_dir.join(".oxi");
494
495 if !dir.exists() {
496 fs::create_dir_all(&dir)
497 .with_context(|| format!("Failed to create project settings directory {}", dir.display()))?;
498 }
499
500 let json_path = dir.join("settings.json");
502 let toml_path = dir.join("settings.toml");
503
504 let path = if json_path.exists() {
505 &json_path
506 } else if toml_path.exists() {
507 &toml_path
508 } else {
509 &json_path
511 };
512
513 let format = Self::detect_format(path);
514 let content = Self::serialize_for_format(self, format)?;
515
516 let tmp_path = path.with_extension("tmp");
518 fs::write(&tmp_path, &content)
519 .with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
520 fs::rename(&tmp_path, path)
521 .with_context(|| format!("Failed to rename settings to {}", path.display()))?;
522
523 Ok(())
524 }
525
526 pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
528 match format {
529 SettingsFormat::Toml => toml::to_string_pretty(settings)
530 .context("Failed to serialize settings to TOML"),
531 SettingsFormat::Json => serde_json::to_string_pretty(settings)
532 .context("Failed to serialize settings to JSON"),
533 }
534 }
535
536 pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
538 match format {
539 SettingsFormat::Toml => toml::from_str(content)
540 .context("Failed to parse TOML settings"),
541 SettingsFormat::Json => serde_json::from_str(content)
542 .context("Failed to parse JSON settings"),
543 }
544 }
545
546 pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
550 if let Some(m) = model {
551 self.default_model = Some(m);
552 }
553 if let Some(p) = provider {
554 self.default_provider = Some(p);
555 }
556 }
557
558 pub fn effective_model(&self, cli_model: Option<&str>) -> String {
560 cli_model
561 .map(String::from)
562 .or_else(|| self.default_model.clone())
563 .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".to_string())
564 }
565
566 pub fn effective_provider(&self, cli_provider: Option<&str>) -> String {
568 cli_provider
569 .map(String::from)
570 .or_else(|| self.default_provider.clone())
571 .unwrap_or_else(|| "anthropic".to_string())
572 }
573
574 pub fn effective_temperature(&self) -> Option<f64> {
577 self.default_temperature
578 .or(self.temperature.map(|t| t as f64))
579 }
580
581 pub fn effective_max_tokens(&self) -> Option<usize> {
584 self.max_response_tokens.or(self.max_tokens.map(|t| t as usize))
585 }
586
587 fn migrate(settings: Settings) -> Result<Settings> {
595 let mut settings = settings;
596
597 match settings.version {
598 SETTINGS_VERSION => {
599 }
601 0 => {
602 if settings.tool_timeout_seconds == 0 {
605 settings.tool_timeout_seconds = default_tool_timeout();
606 }
607 settings.version = SETTINGS_VERSION;
608
609 tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
610 }
611 v if v > SETTINGS_VERSION => {
612 anyhow::bail!(
614 "Settings version {} is newer than supported version {}. \
615 Please update oxi.",
616 v,
617 SETTINGS_VERSION
618 );
619 }
620 v => {
621 tracing::warn!(
623 "Unknown settings version {}, attempting migration to {}",
624 v,
625 SETTINGS_VERSION
626 );
627 settings.version = SETTINGS_VERSION;
628 }
629 }
630
631 Ok(settings)
632 }
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
639pub enum SettingsFormat {
640 #[default]
641 Json,
642 Toml,
643}
644
645impl SettingsFormat {
646 pub fn extension(&self) -> &'static str {
648 match self {
649 SettingsFormat::Json => "json",
650 SettingsFormat::Toml => "toml",
651 }
652 }
653}
654
655fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
659 match toml {
660 toml::Value::String(s) => serde_json::Value::String(s),
661 toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
662 toml::Value::Float(f) => {
663 serde_json::Number::from_f64(f).map(serde_json::Value::Number)
664 .unwrap_or(serde_json::Value::Null)
665 }
666 toml::Value::Boolean(b) => serde_json::Value::Bool(b),
667 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
668 toml::Value::Array(arr) => {
669 serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
670 }
671 toml::Value::Table(table) => {
672 let obj = table
673 .into_iter()
674 .map(|(k, v)| (k, toml_value_to_json(v)))
675 .collect();
676 serde_json::Value::Object(obj)
677 }
678 }
679}
680
681fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
683 match (base, override_) {
684 (serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
686 let mut result = base_map;
687 for (key, override_value) in override_map {
688 let base_value = result.remove(&key);
689 let merged = match base_value {
690 Some(base_v) => merge_json_values(base_v, override_value),
691 None => override_value,
692 };
693 result.insert(key, merged);
694 }
695 serde_json::Value::Object(result)
696 }
697 (_, override_) => override_,
699 }
700}
701
702pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
704 match s.to_lowercase().as_str() {
705 "none" => Some(ThinkingLevel::None),
706 "minimal" => Some(ThinkingLevel::Minimal),
707 "standard" => Some(ThinkingLevel::Standard),
708 "thorough" => Some(ThinkingLevel::Thorough),
709 _ => None,
710 }
711}
712
713fn parse_boolish(s: &str) -> Result<bool> {
715 match s.to_lowercase().as_str() {
716 "true" | "1" | "yes" | "on" => Ok(true),
717 "false" | "0" | "no" | "off" => Ok(false),
718 _ => anyhow::bail!("Cannot parse '{}' as boolean", s),
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use std::io::Write as IoWrite;
726
727 struct EnvGuard {
730 saved: Vec<(String, Option<String>)>,
731 }
732
733 impl EnvGuard {
734 fn new(vars: &[&str]) -> Self {
735 let saved = vars
736 .iter()
737 .map(|&name| {
738 let old = env::var(name).ok();
739 env::remove_var(name);
740 (name.to_string(), old)
741 })
742 .collect();
743 Self { saved }
744 }
745 }
746
747 impl Drop for EnvGuard {
748 fn drop(&mut self) {
749 for (name, old) in self.saved.drain(..) {
750 match old {
751 Some(val) => env::set_var(&name, val),
752 None => env::remove_var(&name),
753 }
754 }
755 }
756 }
757
758 #[test]
761 fn test_default_settings() {
762 let settings = Settings::default();
763 assert_eq!(settings.version, SETTINGS_VERSION);
764 assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
765 assert_eq!(settings.theme, "default");
766 assert!(settings.default_model.is_none());
767 assert!(settings.default_provider.is_none());
768 assert!(settings.extensions_enabled);
769 assert!(settings.auto_compaction);
770 assert_eq!(settings.tool_timeout_seconds, 120);
771 assert!(settings.stream_responses);
772 }
773
774 #[test]
775 fn test_merge_cli() {
776 let mut settings = Settings::default();
777 settings.default_model = Some("openai/gpt-4o".to_string());
778
779 settings.merge_cli(Some("claude".to_string()), None);
780 assert_eq!(settings.default_model, Some("claude".to_string()));
781
782 settings.merge_cli(None, Some("google".to_string()));
783 assert_eq!(settings.default_provider, Some("google".to_string()));
784 }
785
786 #[test]
789 fn test_layer_file_overrides() {
790 let base = Settings::default();
791
792 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
793 let toml_content = r#"
794default_model = "openai/gpt-4o"
795theme = "dracula"
796"#;
797 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
798
799 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
800 assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
801 assert_eq!(merged.theme, "dracula");
802 assert_eq!(merged.thinking_level, ThinkingLevel::Standard);
804 assert!(merged.extensions_enabled);
805 }
806
807 #[test]
808 fn test_layer_file_preserves_unset() {
809 let mut base = Settings::default();
810 base.default_provider = Some("deepseek".to_string());
811
812 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
813 let toml_content = "theme = \"monokai\"\n";
815 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
816
817 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
818 assert_eq!(merged.theme, "monokai");
819 assert_eq!(merged.default_provider, Some("deepseek".to_string()));
820 }
821
822 #[test]
823 fn test_load_from_dir_with_project_config() {
824 let tmp = tempfile::tempdir().unwrap();
825 let oxi_dir = tmp.path().join(".oxi");
826 fs::create_dir_all(&oxi_dir).unwrap();
827 let settings_path = oxi_dir.join("settings.toml");
828 fs::write(&settings_path, "default_model = \"google/gemini-2.0-flash\"\n").unwrap();
829
830 let settings = Settings::load_from(tmp.path()).unwrap();
831 assert_eq!(settings.default_model, Some("google/gemini-2.0-flash".to_string()));
832 }
833
834 #[test]
835 fn test_load_from_dir_no_config() {
836 let _guard = EnvGuard::new(&[
838 "OXI_MODEL",
839 "OXI_PROVIDER",
840 "OXI_THEME",
841 "OXI_TOOL_TIMEOUT",
842 "OXI_TEMPERATURE",
843 "OXI_MAX_TOKENS",
844 "OXI_SESSION_DIR",
845 "OXI_STREAM",
846 "OXI_EXTENSIONS_ENABLED",
847 ]);
848 let tmp = tempfile::tempdir().unwrap();
849 let settings = Settings::load_from(tmp.path()).unwrap();
850 assert!(settings.default_model.is_none());
852 assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
853 }
854
855 #[test]
858 fn test_from_env() {
859 let _guard = EnvGuard::new(&[
860 "OXI_MODEL",
861 "OXI_THEME",
862 "OXI_TOOL_TIMEOUT",
863 ]);
864 env::set_var("OXI_MODEL", "anthropic/claude-haiku-4-20250414");
865 env::set_var("OXI_THEME", "nord");
866 env::set_var("OXI_TOOL_TIMEOUT", "60");
867
868 let settings = Settings::from_env();
869 assert_eq!(settings.default_model, Some("anthropic/claude-haiku-4-20250414".to_string()));
870 assert_eq!(settings.theme, "nord");
871 assert_eq!(settings.tool_timeout_seconds, 60);
872 }
873
874 #[test]
875 fn test_apply_env_boolish() {
876 let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
877 env::set_var("OXI_STREAM", "false");
878 env::set_var("OXI_EXTENSIONS_ENABLED", "0");
879
880 let mut settings = Settings::default();
881 settings.apply_env();
882 assert!(!settings.stream_responses);
883 assert!(!settings.extensions_enabled);
884 }
885
886 #[test]
887 fn test_apply_env_temperature() {
888 let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
889 env::set_var("OXI_TEMPERATURE", "0.7");
890
891 let mut settings = Settings::default();
892 settings.apply_env();
893 assert_eq!(settings.default_temperature, Some(0.7));
894 }
895
896 #[test]
897 fn test_env_does_not_override_when_unset() {
898 let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER"]);
899 let settings = Settings::from_env();
900 assert!(settings.default_model.is_none());
901 assert!(settings.default_provider.is_none());
902 }
903
904 #[test]
907 fn test_parse_thinking_level() {
908 assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::None));
909 assert_eq!(parse_thinking_level("MINIMAL"), Some(ThinkingLevel::Minimal));
910 assert_eq!(parse_thinking_level("Standard"), Some(ThinkingLevel::Standard));
911 assert_eq!(parse_thinking_level("thorough"), Some(ThinkingLevel::Thorough));
912 assert_eq!(parse_thinking_level("invalid"), None);
913 }
914
915 #[test]
916 fn test_parse_boolish() {
917 assert!(parse_boolish("true").unwrap());
918 assert!(parse_boolish("1").unwrap());
919 assert!(parse_boolish("yes").unwrap());
920 assert!(parse_boolish("ON").unwrap());
921 assert!(!parse_boolish("false").unwrap());
922 assert!(!parse_boolish("0").unwrap());
923 assert!(!parse_boolish("no").unwrap());
924 assert!(!parse_boolish("OFF").unwrap());
925 assert!(parse_boolish("maybe").is_err());
926 }
927
928 #[test]
931 fn test_effective_temperature_prefers_f64() {
932 let mut settings = Settings::default();
933 settings.temperature = Some(0.5);
934 settings.default_temperature = Some(0.7);
935 assert_eq!(settings.effective_temperature(), Some(0.7));
936 }
937
938 #[test]
939 fn test_effective_temperature_falls_back_to_f32() {
940 let mut settings = Settings::default();
941 settings.temperature = Some(0.5);
942 assert_eq!(settings.effective_temperature(), Some(0.5));
943 }
944
945 #[test]
946 fn test_effective_max_tokens_prefers_usize() {
947 let mut settings = Settings::default();
948 settings.max_tokens = Some(1024);
949 settings.max_response_tokens = Some(4096);
950 assert_eq!(settings.effective_max_tokens(), Some(4096));
951 }
952
953 #[test]
954 fn test_effective_max_tokens_falls_back_to_u32() {
955 let mut settings = Settings::default();
956 settings.max_tokens = Some(1024);
957 assert_eq!(settings.effective_max_tokens(), Some(1024));
958 }
959
960 #[test]
963 fn test_effective_session_dir_default() {
964 env::remove_var("OXI_SESSION_DIR");
965 let settings = Settings::default();
966 let dir = settings.effective_session_dir().unwrap();
967 assert!(dir.ends_with("sessions"));
968 }
969
970 #[test]
971 fn test_effective_session_dir_from_field() {
972 env::remove_var("OXI_SESSION_DIR");
973 let mut settings = Settings::default();
974 settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
975 assert_eq!(settings.effective_session_dir().unwrap(), PathBuf::from("/tmp/oxi-sessions"));
976 }
977
978 #[test]
979 fn test_effective_session_dir_from_env() {
980 env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
981 let settings = Settings::default();
982 assert_eq!(settings.effective_session_dir().unwrap(), PathBuf::from("/tmp/env-sessions"));
983 env::remove_var("OXI_SESSION_DIR");
984 }
985
986 #[test]
989 fn test_migration_v0_to_v1() {
990 let mut settings = Settings::default();
991 settings.version = 0;
992 settings.tool_timeout_seconds = 0; let migrated = Settings::migrate(settings).unwrap();
995 assert_eq!(migrated.version, SETTINGS_VERSION);
996 assert_eq!(migrated.tool_timeout_seconds, 120);
997 }
998
999 #[test]
1000 fn test_migration_already_current() {
1001 let settings = Settings::default();
1002 let migrated = Settings::migrate(settings).unwrap();
1003 assert_eq!(migrated.version, SETTINGS_VERSION);
1004 }
1005
1006 #[test]
1007 fn test_migration_future_version_fails() {
1008 let mut settings = Settings::default();
1009 settings.version = 9999;
1010 assert!(Settings::migrate(settings).is_err());
1011 }
1012
1013 #[test]
1016 fn test_save_and_load_roundtrip() {
1017 let tmp = tempfile::tempdir().unwrap();
1018 let settings_path = tmp.path().join("settings.toml");
1019
1020 let mut original = Settings::default();
1021 original.default_model = Some("openai/gpt-4o".to_string());
1022 original.theme = "dracula".to_string();
1023 original.tool_timeout_seconds = 60;
1024
1025 let content = toml::to_string_pretty(&original).unwrap();
1027 fs::write(&settings_path, &content).unwrap();
1028
1029 let loaded_content = fs::read_to_string(&settings_path).unwrap();
1031 let loaded: Settings = toml::from_str(&loaded_content).unwrap();
1032
1033 assert_eq!(loaded.default_model, original.default_model);
1034 assert_eq!(loaded.theme, original.theme);
1035 assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
1036 }
1037
1038 #[test]
1039 fn test_toml_roundtrip_preserves_new_fields() {
1040 let mut settings = Settings::default();
1041 settings.default_temperature = Some(0.8);
1042 settings.max_response_tokens = Some(8192);
1043 settings.auto_compaction = false;
1044 settings.extensions_enabled = false;
1045 settings.session_dir = Some(PathBuf::from("/custom/sessions"));
1046
1047 let toml_str = toml::to_string_pretty(&settings).unwrap();
1048 let parsed: Settings = toml::from_str(&toml_str).unwrap();
1049
1050 assert_eq!(parsed.default_temperature, Some(0.8));
1051 assert_eq!(parsed.max_response_tokens, Some(8192));
1052 assert!(!parsed.auto_compaction);
1053 assert!(!parsed.extensions_enabled);
1054 assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
1055 }
1056
1057 #[test]
1060 fn test_json_roundtrip() {
1061 let mut settings = Settings::default();
1062 settings.default_model = Some("openai/gpt-4o".to_string());
1063 settings.theme = "dracula".to_string();
1064 settings.tool_timeout_seconds = 60;
1065 settings.default_temperature = Some(0.8);
1066 settings.max_response_tokens = Some(8192);
1067
1068 let json_str = serde_json::to_string_pretty(&settings).unwrap();
1069 let parsed: Settings = serde_json::from_str(&json_str).unwrap();
1070
1071 assert_eq!(parsed.default_model, settings.default_model);
1072 assert_eq!(parsed.theme, settings.theme);
1073 assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
1074 assert_eq!(parsed.default_temperature, settings.default_temperature);
1075 assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
1076 }
1077
1078 #[test]
1079 fn test_json_serialize_for_format() {
1080 let mut settings = Settings::default();
1081 settings.default_model = Some("anthropic/claude-3".to_string());
1082 settings.thinking_level = ThinkingLevel::Minimal;
1083
1084 let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
1085 let parsed: Settings = serde_json::from_str(&json_content).unwrap();
1086
1087 assert_eq!(parsed.default_model, Some("anthropic/claude-3".to_string()));
1088 assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
1089 }
1090
1091 #[test]
1092 fn test_toml_serialize_for_format() {
1093 let mut settings = Settings::default();
1094 settings.default_model = Some("google/gemini-pro".to_string());
1095 settings.thinking_level = ThinkingLevel::Thorough;
1096
1097 let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
1098 let parsed: Settings = toml::from_str(&toml_content).unwrap();
1099
1100 assert_eq!(parsed.default_model, Some("google/gemini-pro".to_string()));
1101 assert_eq!(parsed.thinking_level, ThinkingLevel::Thorough);
1102 }
1103
1104 #[test]
1105 fn test_parse_from_str_json() {
1106 let json_content = r#"{
1107 "default_model": "openai/gpt-4",
1108 "theme": "nord",
1109 "tool_timeout_seconds": 90
1110 }"#;
1111
1112 let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
1113 assert_eq!(settings.default_model, Some("openai/gpt-4".to_string()));
1114 assert_eq!(settings.theme, "nord");
1115 assert_eq!(settings.tool_timeout_seconds, 90);
1116 assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
1118 assert!(settings.extensions_enabled);
1119 }
1120
1121 #[test]
1122 fn test_parse_from_str_toml() {
1123 let toml_content = r#"
1124default_model = "anthropic/claude-opus"
1125theme = "monokai"
1126tool_timeout_seconds = 45
1127"#;
1128
1129 let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
1130 assert_eq!(settings.default_model, Some("anthropic/claude-opus".to_string()));
1131 assert_eq!(settings.theme, "monokai");
1132 assert_eq!(settings.tool_timeout_seconds, 45);
1133 assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
1134 }
1135
1136 #[test]
1137 fn test_layer_file_json() {
1138 let base = Settings::default();
1139
1140 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1141 let json_content = r#"{
1142 "default_model": "openai/gpt-4o",
1143 "theme": "dracula",
1144 "auto_compaction": false
1145 }"#;
1146 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1147
1148 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1149 assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
1150 assert_eq!(merged.theme, "dracula");
1151 assert!(!merged.auto_compaction);
1152 assert_eq!(merged.thinking_level, ThinkingLevel::Standard);
1154 assert!(merged.extensions_enabled);
1155 assert_eq!(merged.tool_timeout_seconds, 120);
1156 }
1157
1158 #[test]
1159 fn test_layer_file_json_preserves_unset() {
1160 let mut base = Settings::default();
1161 base.default_provider = Some("deepseek".to_string());
1162
1163 let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
1164 let json_content = r#"{ "theme": "nord" }"#;
1165 tmp.as_file().write_all(json_content.as_bytes()).unwrap();
1166
1167 let merged = Settings::layer_file(&base, tmp.path()).unwrap();
1168 assert_eq!(merged.theme, "nord");
1169 assert_eq!(merged.default_provider, Some("deepseek".to_string()));
1170 }
1171
1172 #[test]
1173 fn test_save_to_json() {
1174 let tmp = tempfile::tempdir().unwrap();
1175 let settings_path = tmp.path().join("settings.json");
1176
1177 let mut settings = Settings::default();
1178 settings.default_model = Some("openai/gpt-4o".to_string());
1179 settings.theme = "dracula".to_string();
1180 settings.tool_timeout_seconds = 60;
1181
1182 settings.save_to(&settings_path).unwrap();
1183
1184 let content = fs::read_to_string(&settings_path).unwrap();
1186 let parsed: Settings = serde_json::from_str(&content).unwrap();
1187 assert_eq!(parsed.default_model, Some("openai/gpt-4o".to_string()));
1188 assert_eq!(parsed.theme, "dracula");
1189 assert_eq!(parsed.tool_timeout_seconds, 60);
1190 }
1191
1192 #[test]
1193 fn test_save_to_toml() {
1194 let tmp = tempfile::tempdir().unwrap();
1195 let settings_path = tmp.path().join("settings.toml");
1196
1197 let mut settings = Settings::default();
1198 settings.default_model = Some("google/gemini-pro".to_string());
1199 settings.theme = "monokai".to_string();
1200 settings.tool_timeout_seconds = 90;
1201
1202 settings.save_to(&settings_path).unwrap();
1203
1204 let content = fs::read_to_string(&settings_path).unwrap();
1206 let parsed: Settings = toml::from_str(&content).unwrap();
1207 assert_eq!(parsed.default_model, Some("google/gemini-pro".to_string()));
1208 assert_eq!(parsed.theme, "monokai");
1209 assert_eq!(parsed.tool_timeout_seconds, 90);
1210 }
1211
1212 #[test]
1213 fn test_load_from_dir_with_json_project_config() {
1214 let tmp = tempfile::tempdir().unwrap();
1215 let oxi_dir = tmp.path().join(".oxi");
1216 fs::create_dir_all(&oxi_dir).unwrap();
1217 let settings_path = oxi_dir.join("settings.json");
1218 let json_content = r#"{ "default_model": "google/gemini-2.0-flash" }"#;
1219 fs::write(&settings_path, json_content).unwrap();
1220
1221 let settings = Settings::load_from(tmp.path()).unwrap();
1222 assert_eq!(settings.default_model, Some("google/gemini-2.0-flash".to_string()));
1223 }
1224
1225 #[test]
1226 fn test_find_project_settings_json_priority() {
1227 let tmp = tempfile::tempdir().unwrap();
1228 let oxi_dir = tmp.path().join(".oxi");
1229 fs::create_dir_all(&oxi_dir).unwrap();
1230
1231 let json_path = oxi_dir.join("settings.json");
1233 let toml_path = oxi_dir.join("settings.toml");
1234 fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
1235 fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
1236
1237 let found = Settings::find_project_settings(tmp.path());
1239 assert!(found.is_some());
1240 assert_eq!(found.unwrap().file_name().unwrap().to_str().unwrap(), "settings.json");
1241 }
1242
1243 #[test]
1244 fn test_find_project_settings_json_only() {
1245 let tmp = tempfile::tempdir().unwrap();
1246 let oxi_dir = tmp.path().join(".oxi");
1247 fs::create_dir_all(&oxi_dir).unwrap();
1248
1249 let json_path = oxi_dir.join("settings.json");
1250 fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
1251
1252 let found = Settings::find_project_settings(tmp.path());
1253 assert!(found.is_some());
1254 assert_eq!(found.unwrap().file_name().unwrap().to_str().unwrap(), "settings.json");
1255 }
1256
1257 #[test]
1258 fn test_find_project_settings_toml_fallback() {
1259 let tmp = tempfile::tempdir().unwrap();
1260 let oxi_dir = tmp.path().join(".oxi");
1261 fs::create_dir_all(&oxi_dir).unwrap();
1262
1263 let toml_path = oxi_dir.join("settings.toml");
1264 fs::write(&toml_path, r#"theme = "test""#).unwrap();
1265
1266 let found = Settings::find_project_settings(tmp.path());
1267 assert!(found.is_some());
1268 assert_eq!(found.unwrap().file_name().unwrap().to_str().unwrap(), "settings.toml");
1269 }
1270
1271 #[test]
1272 fn test_detect_format() {
1273 let json_path = PathBuf::from("/test/settings.json");
1274 let toml_path = PathBuf::from("/test/settings.toml");
1275 let unknown_path = PathBuf::from("/test/settings");
1276
1277 assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
1278 assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
1279 assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json); }
1281
1282 #[test]
1283 fn test_settings_format_extension() {
1284 assert_eq!(SettingsFormat::Json.extension(), "json");
1285 assert_eq!(SettingsFormat::Toml.extension(), "toml");
1286 }
1287
1288 #[test]
1289 fn test_layer_json_over_toml() {
1290 let tmp = tempfile::tempdir().unwrap();
1292 let oxi_dir = tmp.path().join(".oxi");
1293 fs::create_dir_all(&oxi_dir).unwrap();
1294
1295 let json_path = oxi_dir.join("settings.json");
1296 let toml_path = oxi_dir.join("settings.toml");
1297
1298 fs::write(&json_path, r#"{ "default_model": "json-model" }"#).unwrap();
1300 fs::write(&toml_path, r#"default_model = "toml-model""#).unwrap();
1302
1303 let settings = Settings::load_from(tmp.path()).unwrap();
1305 assert_eq!(settings.default_model, Some("json-model".to_string()));
1306 }
1307
1308 #[test]
1309 fn test_mixed_format_loading() {
1310 let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
1312 let toml_content = r#"
1313default_model = "loaded-via-toml"
1314theme = "loaded-theme"
1315stream_responses = false
1316"#;
1317 tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
1318
1319 let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
1320 assert_eq!(merged.default_model, Some("loaded-via-toml".to_string()));
1321 assert_eq!(merged.theme, "loaded-theme");
1322 assert!(!merged.stream_responses);
1323 }
1324
1325 #[test]
1326 fn test_merge_json_values() {
1327 use std::collections::HashMap;
1328
1329 let base = serde_json::json!({
1330 "version": 1,
1331 "theme": "default",
1332 "extensions": ["ext1"],
1333 "nested": {
1334 "a": 1,
1335 "b": 2
1336 }
1337 });
1338
1339 let override_ = serde_json::json!({
1340 "version": 2,
1341 "theme": "dark",
1342 "extensions": ["ext2"],
1343 "nested": {
1344 "b": 20,
1345 "c": 30
1346 }
1347 });
1348
1349 let merged = merge_json_values(base, override_);
1350
1351 assert_eq!(merged["version"], 2);
1352 assert_eq!(merged["theme"], "dark");
1353 assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
1355 assert_eq!(merged["nested"]["a"], 1);
1357 assert_eq!(merged["nested"]["b"], 20);
1358 assert_eq!(merged["nested"]["c"], 30);
1359 }
1360
1361 #[test]
1362 fn test_save_project_preserves_existing_format() {
1363 let tmp = tempfile::tempdir().unwrap();
1364 let oxi_dir = tmp.path().join(".oxi");
1365 fs::create_dir_all(&oxi_dir).unwrap();
1366
1367 let toml_path = oxi_dir.join("settings.toml");
1369 fs::write(&toml_path, "theme = 'old-theme'").unwrap();
1370
1371 let mut settings = Settings::default();
1372 settings.theme = "new-theme".to_string();
1373 settings.save_project(tmp.path()).unwrap();
1374
1375 let content = fs::read_to_string(&toml_path).unwrap();
1377 assert!(content.contains("new-theme"));
1378 assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
1379 }
1380
1381 #[test]
1382 fn test_save_project_creates_json_by_default() {
1383 let tmp = tempfile::tempdir().unwrap();
1384 let oxi_dir = tmp.path().join(".oxi");
1385 fs::create_dir_all(&oxi_dir).unwrap();
1386 let mut settings = Settings::default();
1389 settings.theme = "json-theme".to_string();
1390 settings.save_project(tmp.path()).unwrap();
1391
1392 let json_path = oxi_dir.join("settings.json");
1394 assert!(json_path.exists());
1395 let content = fs::read_to_string(&json_path).unwrap();
1396 assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
1397 assert!(content.contains("json-theme"));
1398 }
1399}