use crate::config::{self, strip_line_comments, ConfigError};
#[derive(serde::Deserialize, Default, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
#[serde(default)]
pub default_provider: Option<String>,
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub default_thinking_level: Option<String>,
#[serde(default)]
pub theme: Option<String>,
}
pub fn load_settings() -> Result<Settings, ConfigError> {
let path = config::settings_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
path: path.clone(),
source: e,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
Err(e) => Err(ConfigError::Read { path, source: e }),
}
}
fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
match serde_json::from_str(text) {
Ok(s) => Ok(s),
Err(first) => {
let stripped = strip_line_comments(text);
serde_json::from_str(&stripped).map_err(|_| first)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_support::env_lock;
struct TempConfig {
_guard: std::sync::MutexGuard<'static, ()>,
_tmp: tempfile::TempDir,
prev: Option<std::ffi::OsString>,
}
impl TempConfig {
fn new() -> Self {
let guard = env_lock().lock().unwrap();
let prev = std::env::var_os(config::CONFIG_DIR_ENV);
let tmp = tempfile::TempDir::new().unwrap();
std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
Self { _guard: guard, _tmp: tmp, prev }
}
}
impl Drop for TempConfig {
fn drop(&mut self) {
match self.prev.take() {
Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
None => std::env::remove_var(config::CONFIG_DIR_ENV),
}
}
}
#[test]
fn missing_settings_is_default() {
let _cfg = TempConfig::new();
let s = load_settings().unwrap();
assert!(s.default_provider.is_none());
assert!(s.default_model.is_none());
assert!(s.default_thinking_level.is_none());
assert!(s.theme.is_none());
}
#[test]
fn reads_honored_fields_and_ignores_unknown() {
let _cfg = TempConfig::new();
let path = config::settings_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"{
"lastChangelogVersion": "1.0.0",
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-5",
"defaultThinkingLevel": "high",
"theme": "dark",
"hideThinkingBlock": true,
"compaction": { "threshold": 100 },
"packages": ["some-pkg"]
}"#,
)
.unwrap();
let s = load_settings().unwrap();
assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
assert_eq!(s.theme.as_deref(), Some("dark"));
}
#[test]
fn tolerates_line_comments() {
let _cfg = TempConfig::new();
let path = config::settings_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"{\n // my default\n \"defaultModel\": \"glm-5\",\n \"theme\": \"light\"\n}\n",
)
.unwrap();
let s = load_settings().unwrap();
assert_eq!(s.default_model.as_deref(), Some("glm-5"));
assert_eq!(s.theme.as_deref(), Some("light"));
}
#[test]
fn malformed_is_error() {
let _cfg = TempConfig::new();
let path = config::settings_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{ not json").unwrap();
assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
}
}