use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const SETTINGS_VERSION: u32 = 2;
#[allow(dead_code)]
const ENV_PREFIX: &str = "OXI_";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThinkingLevel {
None,
Minimal,
#[default]
Standard,
Thorough,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub thinking_level: ThinkingLevel,
#[serde(default = "default_theme")]
pub theme: String,
pub default_model: Option<String>,
pub default_provider: Option<String>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub default_temperature: Option<f64>,
pub max_response_tokens: Option<usize>,
#[serde(default = "default_session_history_size")]
pub session_history_size: usize,
pub session_dir: Option<PathBuf>,
#[serde(default = "default_true")]
pub stream_responses: bool,
#[serde(default = "default_true")]
pub extensions_enabled: bool,
#[serde(default = "default_true")]
pub auto_compaction: bool,
#[serde(default = "default_tool_timeout")]
pub tool_timeout_seconds: u64,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default)]
pub skills: Vec<String>,
#[serde(default)]
pub prompts: Vec<String>,
#[serde(default)]
pub themes: Vec<String>,
}
fn default_theme() -> String {
"default".to_string()
}
fn default_session_history_size() -> usize {
100
}
fn default_true() -> bool {
true
}
fn default_tool_timeout() -> u64 {
120
}
impl Default for Settings {
fn default() -> Self {
Self {
version: SETTINGS_VERSION,
thinking_level: ThinkingLevel::Standard,
theme: default_theme(),
default_model: None,
default_provider: None,
max_tokens: None,
temperature: None,
default_temperature: None,
max_response_tokens: None,
session_history_size: default_session_history_size(),
session_dir: None,
stream_responses: true,
extensions_enabled: true,
auto_compaction: true,
tool_timeout_seconds: default_tool_timeout(),
extensions: Vec::new(),
skills: Vec::new(),
prompts: Vec::new(),
themes: Vec::new(),
}
}
}
impl Settings {
pub fn settings_dir() -> Result<PathBuf> {
let base = dirs::home_dir().context("Cannot determine home directory")?;
Ok(base.join(".oxi"))
}
pub fn settings_toml_path() -> Result<PathBuf> {
Ok(Self::settings_dir()?.join("settings.toml"))
}
pub fn settings_json_path() -> Result<PathBuf> {
Ok(Self::settings_dir()?.join("settings.json"))
}
pub fn settings_path() -> Result<PathBuf> {
let json_path = Self::settings_json_path()?;
let toml_path = Self::settings_toml_path()?;
if json_path.exists() && toml_path.exists() {
tracing::debug!("Both settings.json and settings.toml exist, using settings.json");
return Ok(json_path);
}
if json_path.exists() {
return Ok(json_path);
}
if toml_path.exists() {
return Ok(toml_path);
}
Ok(json_path)
}
pub fn settings_path_with_preference(prefer_json: bool) -> Result<PathBuf> {
let json_path = Self::settings_json_path()?;
let toml_path = Self::settings_toml_path()?;
let (primary, secondary) = if prefer_json {
(&json_path, &toml_path)
} else {
(&toml_path, &json_path)
};
if primary.exists() {
return Ok(primary.clone());
}
if secondary.exists() {
return Ok(secondary.clone());
}
Ok(primary.clone())
}
pub fn detect_format(path: &Path) -> SettingsFormat {
match path.extension().and_then(|e| e.to_str()) {
Some("json") => SettingsFormat::Json,
Some("toml") => SettingsFormat::Toml,
_ => SettingsFormat::Json, }
}
pub fn find_project_settings(start_dir: &std::path::Path) -> Option<PathBuf> {
let mut dir = start_dir.to_path_buf();
loop {
let json_candidate = dir.join(".oxi").join("settings.json");
if json_candidate.exists() {
return Some(json_candidate);
}
let toml_candidate = dir.join(".oxi").join("settings.toml");
if toml_candidate.exists() {
return Some(toml_candidate);
}
if !dir.pop() {
return None;
}
}
}
pub fn effective_session_dir(&self) -> Result<PathBuf> {
if let Some(ref dir) = self.session_dir {
return Ok(dir.clone());
}
if let Ok(dir) = env::var("OXI_SESSION_DIR") {
return Ok(PathBuf::from(dir));
}
Ok(Self::settings_dir()?.join("sessions"))
}
pub fn load() -> Result<Self> {
Self::load_from_cwd()
}
pub fn load_from(dir: &std::path::Path) -> Result<Self> {
let mut settings = Settings::default();
if let Ok(global_path) = Self::settings_path() {
if global_path.exists() {
settings = Self::layer_file(&settings, &global_path)?;
}
}
if let Some(project_path) = Self::find_project_settings(dir) {
settings = Self::layer_file(&settings, &project_path)?;
}
settings.apply_env();
settings = Self::migrate(settings)?;
Ok(settings)
}
pub fn load_from_cwd() -> Result<Self> {
let cwd = env::current_dir().context("Cannot determine current directory")?;
Self::load_from(&cwd)
}
fn layer_file(base: &Settings, path: &std::path::Path) -> Result<Settings> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read settings from {}", path.display()))?;
let format = Self::detect_format(path);
let overlay: serde_json::Value = match format {
SettingsFormat::Toml => {
let toml_value: toml::Value = toml::from_str(&content).with_context(|| {
format!("Failed to parse TOML settings from {}", path.display())
})?;
toml_value_to_json(toml_value)
}
SettingsFormat::Json => serde_json::from_str(&content).with_context(|| {
format!("Failed to parse JSON settings from {}", path.display())
})?,
};
let base_json =
serde_json::to_value(base).context("Failed to serialize base settings for merge")?;
let merged = merge_json_values(base_json, overlay);
let result: Settings =
serde_json::from_value(merged).context("Failed to deserialize merged settings")?;
Ok(result)
}
pub fn apply_env(&mut self) {
if let Ok(v) = env::var("OXI_MODEL") {
self.default_model = Some(v);
}
if let Ok(v) = env::var("OXI_PROVIDER") {
self.default_provider = Some(v);
}
if let Ok(v) = env::var("OXI_THINKING") {
if let Some(level) = parse_thinking_level(&v) {
self.thinking_level = level;
}
}
if let Ok(v) = env::var("OXI_THEME") {
self.theme = v;
}
if let Ok(v) = env::var("OXI_MAX_TOKENS") {
if let Ok(n) = v.parse::<u32>() {
self.max_tokens = Some(n);
}
}
if let Ok(v) = env::var("OXI_TEMPERATURE") {
if let Ok(n) = v.parse::<f64>() {
self.default_temperature = Some(n);
}
}
if let Ok(v) = env::var("OXI_SESSION_DIR") {
self.session_dir = Some(PathBuf::from(v));
}
if let Ok(v) = env::var("OXI_STREAM") {
if let Ok(b) = parse_boolish(&v) {
self.stream_responses = b;
}
}
if let Ok(v) = env::var("OXI_EXTENSIONS_ENABLED") {
if let Ok(b) = parse_boolish(&v) {
self.extensions_enabled = b;
}
}
if let Ok(v) = env::var("OXI_AUTO_COMPACTION") {
if let Ok(b) = parse_boolish(&v) {
self.auto_compaction = b;
}
}
if let Ok(v) = env::var("OXI_TOOL_TIMEOUT") {
if let Ok(n) = v.parse::<u64>() {
self.tool_timeout_seconds = n;
}
}
}
pub fn from_env() -> Self {
let mut settings = Self::default();
settings.apply_env();
settings
}
pub fn save(&self) -> Result<()> {
let dir = Self::settings_dir()?;
let path = Self::settings_path()?;
if !dir.exists() {
fs::create_dir_all(&dir).with_context(|| {
format!("Failed to create settings directory {}", dir.display())
})?;
}
let format = Self::detect_format(&path);
let content = Self::serialize_for_format(self, format)?;
let tmp_path = path.with_extension("tmp");
fs::write(&tmp_path, &content)
.with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
fs::rename(&tmp_path, &path)
.with_context(|| format!("Failed to rename settings to {}", path.display()))?;
Ok(())
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
}
let format = Self::detect_format(path);
let content = Self::serialize_for_format(self, format)?;
let tmp_path = path.with_extension("tmp");
fs::write(&tmp_path, &content)
.with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
fs::rename(&tmp_path, path)
.with_context(|| format!("Failed to rename settings to {}", path.display()))?;
Ok(())
}
pub fn save_project(&self, project_dir: &std::path::Path) -> Result<()> {
let dir = project_dir.join(".oxi");
if !dir.exists() {
fs::create_dir_all(&dir).with_context(|| {
format!(
"Failed to create project settings directory {}",
dir.display()
)
})?;
}
let json_path = dir.join("settings.json");
let toml_path = dir.join("settings.toml");
let path = if json_path.exists() {
&json_path
} else if toml_path.exists() {
&toml_path
} else {
&json_path
};
let format = Self::detect_format(path);
let content = Self::serialize_for_format(self, format)?;
let tmp_path = path.with_extension("tmp");
fs::write(&tmp_path, &content)
.with_context(|| format!("Failed to write settings to {}", tmp_path.display()))?;
fs::rename(&tmp_path, path)
.with_context(|| format!("Failed to rename settings to {}", path.display()))?;
Ok(())
}
pub fn serialize_for_format(settings: &Settings, format: SettingsFormat) -> Result<String> {
match format {
SettingsFormat::Toml => {
toml::to_string_pretty(settings).context("Failed to serialize settings to TOML")
}
SettingsFormat::Json => serde_json::to_string_pretty(settings)
.context("Failed to serialize settings to JSON"),
}
}
pub fn parse_from_str(content: &str, format: SettingsFormat) -> Result<Settings> {
match format {
SettingsFormat::Toml => {
toml::from_str(content).context("Failed to parse TOML settings")
}
SettingsFormat::Json => {
serde_json::from_str(content).context("Failed to parse JSON settings")
}
}
}
pub fn merge_cli(&mut self, model: Option<String>, provider: Option<String>) {
if let Some(m) = model {
self.default_model = Some(m);
}
if let Some(p) = provider {
self.default_provider = Some(p);
}
}
pub fn effective_model(&self, cli_model: Option<&str>) -> String {
cli_model
.map(String::from)
.or_else(|| self.default_model.clone())
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".to_string())
}
pub fn effective_provider(&self, cli_provider: Option<&str>) -> String {
cli_provider
.map(String::from)
.or_else(|| self.default_provider.clone())
.unwrap_or_else(|| "anthropic".to_string())
}
pub fn effective_temperature(&self) -> Option<f64> {
self.default_temperature
.or(self.temperature.map(|t| t as f64))
}
pub fn effective_max_tokens(&self) -> Option<usize> {
self.max_response_tokens
.or(self.max_tokens.map(|t| t as usize))
}
pub fn save_theme(&mut self, name: &str) -> Result<()> {
self.theme = name.to_string();
self.save()
}
pub fn get_theme_name(&self) -> String {
if self.theme.is_empty() || self.theme == "default" {
"oxi_dark".to_string()
} else {
self.theme.clone()
}
}
fn migrate(settings: Settings) -> Result<Settings> {
let mut settings = settings;
match settings.version {
SETTINGS_VERSION => {
}
0 => {
if settings.tool_timeout_seconds == 0 {
settings.tool_timeout_seconds = default_tool_timeout();
}
settings.version = SETTINGS_VERSION;
tracing::info!("Migrated settings from version 0 to {}", SETTINGS_VERSION);
}
v if v > SETTINGS_VERSION => {
anyhow::bail!(
"Settings version {} is newer than supported version {}. \
Please update oxi.",
v,
SETTINGS_VERSION
);
}
v => {
tracing::warn!(
"Unknown settings version {}, attempting migration to {}",
v,
SETTINGS_VERSION
);
settings.version = SETTINGS_VERSION;
}
}
Ok(settings)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SettingsFormat {
#[default]
Json,
Toml,
}
impl SettingsFormat {
pub fn extension(&self) -> &'static str {
match self {
SettingsFormat::Json => "json",
SettingsFormat::Toml => "toml",
}
}
}
fn toml_value_to_json(toml: toml::Value) -> serde_json::Value {
match toml {
toml::Value::String(s) => serde_json::Value::String(s),
toml::Value::Integer(i) => serde_json::Value::Number(i.into()),
toml::Value::Float(f) => serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
toml::Value::Boolean(b) => serde_json::Value::Bool(b),
toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
toml::Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(toml_value_to_json).collect())
}
toml::Value::Table(table) => {
let obj = table
.into_iter()
.map(|(k, v)| (k, toml_value_to_json(v)))
.collect();
serde_json::Value::Object(obj)
}
}
}
fn merge_json_values(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
match (base, override_) {
(serde_json::Value::Object(base_map), serde_json::Value::Object(override_map)) => {
let mut result = base_map;
for (key, override_value) in override_map {
let base_value = result.remove(&key);
let merged = match base_value {
Some(base_v) => merge_json_values(base_v, override_value),
None => override_value,
};
result.insert(key, merged);
}
serde_json::Value::Object(result)
}
(_, override_) => override_,
}
}
pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
match s.to_lowercase().as_str() {
"none" => Some(ThinkingLevel::None),
"minimal" => Some(ThinkingLevel::Minimal),
"standard" => Some(ThinkingLevel::Standard),
"thorough" => Some(ThinkingLevel::Thorough),
_ => None,
}
}
fn parse_boolish(s: &str) -> Result<bool> {
match s.to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(true),
"false" | "0" | "no" | "off" => Ok(false),
_ => anyhow::bail!("Cannot parse '{}' as boolean", s),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as IoWrite;
struct EnvGuard {
saved: Vec<(String, Option<String>)>,
}
impl EnvGuard {
fn new(vars: &[&str]) -> Self {
let saved = vars
.iter()
.map(|&name| {
let old = env::var(name).ok();
env::remove_var(name);
(name.to_string(), old)
})
.collect();
Self { saved }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (name, old) in self.saved.drain(..) {
match old {
Some(val) => env::set_var(&name, val),
None => env::remove_var(&name),
}
}
}
}
#[test]
fn test_default_settings() {
let settings = Settings::default();
assert_eq!(settings.version, SETTINGS_VERSION);
assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
assert_eq!(settings.theme, "default");
assert!(settings.default_model.is_none());
assert!(settings.default_provider.is_none());
assert!(settings.extensions_enabled);
assert!(settings.auto_compaction);
assert_eq!(settings.tool_timeout_seconds, 120);
assert!(settings.stream_responses);
}
#[test]
fn test_merge_cli() {
let mut settings = Settings::default();
settings.default_model = Some("openai/gpt-4o".to_string());
settings.merge_cli(Some("claude".to_string()), None);
assert_eq!(settings.default_model, Some("claude".to_string()));
settings.merge_cli(None, Some("google".to_string()));
assert_eq!(settings.default_provider, Some("google".to_string()));
}
#[test]
fn test_layer_file_overrides() {
let base = Settings::default();
let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
let toml_content = r#"
default_model = "openai/gpt-4o"
theme = "dracula"
"#;
tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
let merged = Settings::layer_file(&base, tmp.path()).unwrap();
assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
assert_eq!(merged.theme, "dracula");
assert_eq!(merged.thinking_level, ThinkingLevel::Standard);
assert!(merged.extensions_enabled);
}
#[test]
fn test_layer_file_preserves_unset() {
let mut base = Settings::default();
base.default_provider = Some("deepseek".to_string());
let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
let toml_content = "theme = \"monokai\"\n";
tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
let merged = Settings::layer_file(&base, tmp.path()).unwrap();
assert_eq!(merged.theme, "monokai");
assert_eq!(merged.default_provider, Some("deepseek".to_string()));
}
#[test]
fn test_load_from_dir_with_project_config() {
let tmp = tempfile::tempdir().unwrap();
let oxi_dir = tmp.path().join(".oxi");
fs::create_dir_all(&oxi_dir).unwrap();
let settings_path = oxi_dir.join("settings.toml");
fs::write(
&settings_path,
"default_model = \"google/gemini-2.0-flash\"\n",
)
.unwrap();
let settings = Settings::load_from(tmp.path()).unwrap();
assert_eq!(
settings.default_model,
Some("google/gemini-2.0-flash".to_string())
);
}
#[test]
fn test_load_from_dir_no_config() {
let _guard = EnvGuard::new(&[
"OXI_MODEL",
"OXI_PROVIDER",
"OXI_THEME",
"OXI_TOOL_TIMEOUT",
"OXI_TEMPERATURE",
"OXI_MAX_TOKENS",
"OXI_SESSION_DIR",
"OXI_STREAM",
"OXI_EXTENSIONS_ENABLED",
]);
let tmp = tempfile::tempdir().unwrap();
let settings = Settings::load_from(tmp.path()).unwrap();
assert!(settings.default_model.is_none());
assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
}
#[test]
fn test_from_env() {
let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_THEME", "OXI_TOOL_TIMEOUT"]);
env::set_var("OXI_MODEL", "anthropic/claude-haiku-4-20250414");
env::set_var("OXI_THEME", "nord");
env::set_var("OXI_TOOL_TIMEOUT", "60");
let settings = Settings::from_env();
assert_eq!(
settings.default_model,
Some("anthropic/claude-haiku-4-20250414".to_string())
);
assert_eq!(settings.theme, "nord");
assert_eq!(settings.tool_timeout_seconds, 60);
}
#[test]
fn test_apply_env_boolish() {
let _guard = EnvGuard::new(&["OXI_STREAM", "OXI_EXTENSIONS_ENABLED"]);
env::set_var("OXI_STREAM", "false");
env::set_var("OXI_EXTENSIONS_ENABLED", "0");
let mut settings = Settings::default();
settings.apply_env();
assert!(!settings.stream_responses);
assert!(!settings.extensions_enabled);
}
#[test]
fn test_apply_env_temperature() {
let _guard = EnvGuard::new(&["OXI_TEMPERATURE"]);
env::set_var("OXI_TEMPERATURE", "0.7");
let mut settings = Settings::default();
settings.apply_env();
assert_eq!(settings.default_temperature, Some(0.7));
}
#[test]
fn test_env_does_not_override_when_unset() {
let _guard = EnvGuard::new(&["OXI_MODEL", "OXI_PROVIDER"]);
let settings = Settings::from_env();
assert!(settings.default_model.is_none());
assert!(settings.default_provider.is_none());
}
#[test]
fn test_parse_thinking_level() {
assert_eq!(parse_thinking_level("none"), Some(ThinkingLevel::None));
assert_eq!(
parse_thinking_level("MINIMAL"),
Some(ThinkingLevel::Minimal)
);
assert_eq!(
parse_thinking_level("Standard"),
Some(ThinkingLevel::Standard)
);
assert_eq!(
parse_thinking_level("thorough"),
Some(ThinkingLevel::Thorough)
);
assert_eq!(parse_thinking_level("invalid"), None);
}
#[test]
fn test_parse_boolish() {
assert!(parse_boolish("true").unwrap());
assert!(parse_boolish("1").unwrap());
assert!(parse_boolish("yes").unwrap());
assert!(parse_boolish("ON").unwrap());
assert!(!parse_boolish("false").unwrap());
assert!(!parse_boolish("0").unwrap());
assert!(!parse_boolish("no").unwrap());
assert!(!parse_boolish("OFF").unwrap());
assert!(parse_boolish("maybe").is_err());
}
#[test]
fn test_effective_temperature_prefers_f64() {
let mut settings = Settings::default();
settings.temperature = Some(0.5);
settings.default_temperature = Some(0.7);
assert_eq!(settings.effective_temperature(), Some(0.7));
}
#[test]
fn test_effective_temperature_falls_back_to_f32() {
let mut settings = Settings::default();
settings.temperature = Some(0.5);
assert_eq!(settings.effective_temperature(), Some(0.5));
}
#[test]
fn test_effective_max_tokens_prefers_usize() {
let mut settings = Settings::default();
settings.max_tokens = Some(1024);
settings.max_response_tokens = Some(4096);
assert_eq!(settings.effective_max_tokens(), Some(4096));
}
#[test]
fn test_effective_max_tokens_falls_back_to_u32() {
let mut settings = Settings::default();
settings.max_tokens = Some(1024);
assert_eq!(settings.effective_max_tokens(), Some(1024));
}
#[test]
fn test_effective_session_dir_default() {
env::remove_var("OXI_SESSION_DIR");
let settings = Settings::default();
let dir = settings.effective_session_dir().unwrap();
assert!(dir.ends_with("sessions"));
}
#[test]
fn test_effective_session_dir_from_field() {
env::remove_var("OXI_SESSION_DIR");
let mut settings = Settings::default();
settings.session_dir = Some(PathBuf::from("/tmp/oxi-sessions"));
assert_eq!(
settings.effective_session_dir().unwrap(),
PathBuf::from("/tmp/oxi-sessions")
);
}
#[test]
fn test_effective_session_dir_from_env() {
env::set_var("OXI_SESSION_DIR", "/tmp/env-sessions");
let settings = Settings::default();
assert_eq!(
settings.effective_session_dir().unwrap(),
PathBuf::from("/tmp/env-sessions")
);
env::remove_var("OXI_SESSION_DIR");
}
#[test]
fn test_migration_v0_to_v1() {
let mut settings = Settings::default();
settings.version = 0;
settings.tool_timeout_seconds = 0;
let migrated = Settings::migrate(settings).unwrap();
assert_eq!(migrated.version, SETTINGS_VERSION);
assert_eq!(migrated.tool_timeout_seconds, 120);
}
#[test]
fn test_migration_already_current() {
let settings = Settings::default();
let migrated = Settings::migrate(settings).unwrap();
assert_eq!(migrated.version, SETTINGS_VERSION);
}
#[test]
fn test_migration_future_version_fails() {
let mut settings = Settings::default();
settings.version = 9999;
assert!(Settings::migrate(settings).is_err());
}
#[test]
fn test_save_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let settings_path = tmp.path().join("settings.toml");
let mut original = Settings::default();
original.default_model = Some("openai/gpt-4o".to_string());
original.theme = "dracula".to_string();
original.tool_timeout_seconds = 60;
let content = toml::to_string_pretty(&original).unwrap();
fs::write(&settings_path, &content).unwrap();
let loaded_content = fs::read_to_string(&settings_path).unwrap();
let loaded: Settings = toml::from_str(&loaded_content).unwrap();
assert_eq!(loaded.default_model, original.default_model);
assert_eq!(loaded.theme, original.theme);
assert_eq!(loaded.tool_timeout_seconds, original.tool_timeout_seconds);
}
#[test]
fn test_toml_roundtrip_preserves_new_fields() {
let mut settings = Settings::default();
settings.default_temperature = Some(0.8);
settings.max_response_tokens = Some(8192);
settings.auto_compaction = false;
settings.extensions_enabled = false;
settings.session_dir = Some(PathBuf::from("/custom/sessions"));
let toml_str = toml::to_string_pretty(&settings).unwrap();
let parsed: Settings = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.default_temperature, Some(0.8));
assert_eq!(parsed.max_response_tokens, Some(8192));
assert!(!parsed.auto_compaction);
assert!(!parsed.extensions_enabled);
assert_eq!(parsed.session_dir, Some(PathBuf::from("/custom/sessions")));
}
#[test]
fn test_json_roundtrip() {
let mut settings = Settings::default();
settings.default_model = Some("openai/gpt-4o".to_string());
settings.theme = "dracula".to_string();
settings.tool_timeout_seconds = 60;
settings.default_temperature = Some(0.8);
settings.max_response_tokens = Some(8192);
let json_str = serde_json::to_string_pretty(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed.default_model, settings.default_model);
assert_eq!(parsed.theme, settings.theme);
assert_eq!(parsed.tool_timeout_seconds, settings.tool_timeout_seconds);
assert_eq!(parsed.default_temperature, settings.default_temperature);
assert_eq!(parsed.max_response_tokens, settings.max_response_tokens);
}
#[test]
fn test_json_serialize_for_format() {
let mut settings = Settings::default();
settings.default_model = Some("anthropic/claude-3".to_string());
settings.thinking_level = ThinkingLevel::Minimal;
let json_content = Settings::serialize_for_format(&settings, SettingsFormat::Json).unwrap();
let parsed: Settings = serde_json::from_str(&json_content).unwrap();
assert_eq!(parsed.default_model, Some("anthropic/claude-3".to_string()));
assert_eq!(parsed.thinking_level, ThinkingLevel::Minimal);
}
#[test]
fn test_toml_serialize_for_format() {
let mut settings = Settings::default();
settings.default_model = Some("google/gemini-pro".to_string());
settings.thinking_level = ThinkingLevel::Thorough;
let toml_content = Settings::serialize_for_format(&settings, SettingsFormat::Toml).unwrap();
let parsed: Settings = toml::from_str(&toml_content).unwrap();
assert_eq!(parsed.default_model, Some("google/gemini-pro".to_string()));
assert_eq!(parsed.thinking_level, ThinkingLevel::Thorough);
}
#[test]
fn test_parse_from_str_json() {
let json_content = r#"{
"default_model": "openai/gpt-4",
"theme": "nord",
"tool_timeout_seconds": 90
}"#;
let settings = Settings::parse_from_str(json_content, SettingsFormat::Json).unwrap();
assert_eq!(settings.default_model, Some("openai/gpt-4".to_string()));
assert_eq!(settings.theme, "nord");
assert_eq!(settings.tool_timeout_seconds, 90);
assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
assert!(settings.extensions_enabled);
}
#[test]
fn test_parse_from_str_toml() {
let toml_content = r#"
default_model = "anthropic/claude-opus"
theme = "monokai"
tool_timeout_seconds = 45
"#;
let settings = Settings::parse_from_str(toml_content, SettingsFormat::Toml).unwrap();
assert_eq!(
settings.default_model,
Some("anthropic/claude-opus".to_string())
);
assert_eq!(settings.theme, "monokai");
assert_eq!(settings.tool_timeout_seconds, 45);
assert_eq!(settings.thinking_level, ThinkingLevel::Standard);
}
#[test]
fn test_layer_file_json() {
let base = Settings::default();
let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
let json_content = r#"{
"default_model": "openai/gpt-4o",
"theme": "dracula",
"auto_compaction": false
}"#;
tmp.as_file().write_all(json_content.as_bytes()).unwrap();
let merged = Settings::layer_file(&base, tmp.path()).unwrap();
assert_eq!(merged.default_model, Some("openai/gpt-4o".to_string()));
assert_eq!(merged.theme, "dracula");
assert!(!merged.auto_compaction);
assert_eq!(merged.thinking_level, ThinkingLevel::Standard);
assert!(merged.extensions_enabled);
assert_eq!(merged.tool_timeout_seconds, 120);
}
#[test]
fn test_layer_file_json_preserves_unset() {
let mut base = Settings::default();
base.default_provider = Some("deepseek".to_string());
let tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap();
let json_content = r#"{ "theme": "nord" }"#;
tmp.as_file().write_all(json_content.as_bytes()).unwrap();
let merged = Settings::layer_file(&base, tmp.path()).unwrap();
assert_eq!(merged.theme, "nord");
assert_eq!(merged.default_provider, Some("deepseek".to_string()));
}
#[test]
fn test_save_to_json() {
let tmp = tempfile::tempdir().unwrap();
let settings_path = tmp.path().join("settings.json");
let mut settings = Settings::default();
settings.default_model = Some("openai/gpt-4o".to_string());
settings.theme = "dracula".to_string();
settings.tool_timeout_seconds = 60;
settings.save_to(&settings_path).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let parsed: Settings = serde_json::from_str(&content).unwrap();
assert_eq!(parsed.default_model, Some("openai/gpt-4o".to_string()));
assert_eq!(parsed.theme, "dracula");
assert_eq!(parsed.tool_timeout_seconds, 60);
}
#[test]
fn test_save_to_toml() {
let tmp = tempfile::tempdir().unwrap();
let settings_path = tmp.path().join("settings.toml");
let mut settings = Settings::default();
settings.default_model = Some("google/gemini-pro".to_string());
settings.theme = "monokai".to_string();
settings.tool_timeout_seconds = 90;
settings.save_to(&settings_path).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let parsed: Settings = toml::from_str(&content).unwrap();
assert_eq!(parsed.default_model, Some("google/gemini-pro".to_string()));
assert_eq!(parsed.theme, "monokai");
assert_eq!(parsed.tool_timeout_seconds, 90);
}
#[test]
fn test_load_from_dir_with_json_project_config() {
let tmp = tempfile::tempdir().unwrap();
let oxi_dir = tmp.path().join(".oxi");
fs::create_dir_all(&oxi_dir).unwrap();
let settings_path = oxi_dir.join("settings.json");
let json_content = r#"{ "default_model": "google/gemini-2.0-flash" }"#;
fs::write(&settings_path, json_content).unwrap();
let settings = Settings::load_from(tmp.path()).unwrap();
assert_eq!(
settings.default_model,
Some("google/gemini-2.0-flash".to_string())
);
}
#[test]
fn test_find_project_settings_json_priority() {
let tmp = tempfile::tempdir().unwrap();
let oxi_dir = tmp.path().join(".oxi");
fs::create_dir_all(&oxi_dir).unwrap();
let json_path = oxi_dir.join("settings.json");
let toml_path = oxi_dir.join("settings.toml");
fs::write(&json_path, r#"{ "theme": "json-theme" }"#).unwrap();
fs::write(&toml_path, r#"theme = "toml-theme""#).unwrap();
let found = Settings::find_project_settings(tmp.path());
assert!(found.is_some());
assert_eq!(
found.unwrap().file_name().unwrap().to_str().unwrap(),
"settings.json"
);
}
#[test]
fn test_find_project_settings_json_only() {
let tmp = tempfile::tempdir().unwrap();
let oxi_dir = tmp.path().join(".oxi");
fs::create_dir_all(&oxi_dir).unwrap();
let json_path = oxi_dir.join("settings.json");
fs::write(&json_path, r#"{ "theme": "test" }"#).unwrap();
let found = Settings::find_project_settings(tmp.path());
assert!(found.is_some());
assert_eq!(
found.unwrap().file_name().unwrap().to_str().unwrap(),
"settings.json"
);
}
#[test]
fn test_find_project_settings_toml_fallback() {
let tmp = tempfile::tempdir().unwrap();
let oxi_dir = tmp.path().join(".oxi");
fs::create_dir_all(&oxi_dir).unwrap();
let toml_path = oxi_dir.join("settings.toml");
fs::write(&toml_path, r#"theme = "test""#).unwrap();
let found = Settings::find_project_settings(tmp.path());
assert!(found.is_some());
assert_eq!(
found.unwrap().file_name().unwrap().to_str().unwrap(),
"settings.toml"
);
}
#[test]
fn test_detect_format() {
let json_path = PathBuf::from("/test/settings.json");
let toml_path = PathBuf::from("/test/settings.toml");
let unknown_path = PathBuf::from("/test/settings");
assert_eq!(Settings::detect_format(&json_path), SettingsFormat::Json);
assert_eq!(Settings::detect_format(&toml_path), SettingsFormat::Toml);
assert_eq!(Settings::detect_format(&unknown_path), SettingsFormat::Json);
}
#[test]
fn test_settings_format_extension() {
assert_eq!(SettingsFormat::Json.extension(), "json");
assert_eq!(SettingsFormat::Toml.extension(), "toml");
}
#[test]
fn test_layer_json_over_toml() {
let tmp = tempfile::tempdir().unwrap();
let oxi_dir = tmp.path().join(".oxi");
fs::create_dir_all(&oxi_dir).unwrap();
let json_path = oxi_dir.join("settings.json");
let toml_path = oxi_dir.join("settings.toml");
fs::write(&json_path, r#"{ "default_model": "json-model" }"#).unwrap();
fs::write(&toml_path, r#"default_model = "toml-model""#).unwrap();
let settings = Settings::load_from(tmp.path()).unwrap();
assert_eq!(settings.default_model, Some("json-model".to_string()));
}
#[test]
fn test_mixed_format_loading() {
let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
let toml_content = r#"
default_model = "loaded-via-toml"
theme = "loaded-theme"
stream_responses = false
"#;
tmp.as_file().write_all(toml_content.as_bytes()).unwrap();
let merged = Settings::layer_file(&Settings::default(), tmp.path()).unwrap();
assert_eq!(merged.default_model, Some("loaded-via-toml".to_string()));
assert_eq!(merged.theme, "loaded-theme");
assert!(!merged.stream_responses);
}
#[test]
fn test_merge_json_values() {
use std::collections::HashMap;
let base = serde_json::json!({
"version": 1,
"theme": "default",
"extensions": ["ext1"],
"nested": {
"a": 1,
"b": 2
}
});
let override_ = serde_json::json!({
"version": 2,
"theme": "dark",
"extensions": ["ext2"],
"nested": {
"b": 20,
"c": 30
}
});
let merged = merge_json_values(base, override_);
assert_eq!(merged["version"], 2);
assert_eq!(merged["theme"], "dark");
assert_eq!(merged["extensions"], serde_json::json!(["ext2"]));
assert_eq!(merged["nested"]["a"], 1);
assert_eq!(merged["nested"]["b"], 20);
assert_eq!(merged["nested"]["c"], 30);
}
#[test]
fn test_save_project_preserves_existing_format() {
let tmp = tempfile::tempdir().unwrap();
let oxi_dir = tmp.path().join(".oxi");
fs::create_dir_all(&oxi_dir).unwrap();
let toml_path = oxi_dir.join("settings.toml");
fs::write(&toml_path, "theme = 'old-theme'").unwrap();
let mut settings = Settings::default();
settings.theme = "new-theme".to_string();
settings.save_project(tmp.path()).unwrap();
let content = fs::read_to_string(&toml_path).unwrap();
assert!(content.contains("new-theme"));
assert!(serde_json::from_str::<serde_json::Value>(&content).is_err());
}
#[test]
fn test_save_project_creates_json_by_default() {
let tmp = tempfile::tempdir().unwrap();
let oxi_dir = tmp.path().join(".oxi");
fs::create_dir_all(&oxi_dir).unwrap();
let mut settings = Settings::default();
settings.theme = "json-theme".to_string();
settings.save_project(tmp.path()).unwrap();
let json_path = oxi_dir.join("settings.json");
assert!(json_path.exists());
let content = fs::read_to_string(&json_path).unwrap();
assert!(serde_json::from_str::<serde_json::Value>(&content).is_ok());
assert!(content.contains("json-theme"));
}
}