use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::PathBuf;
fn is_false(v: &bool) -> bool {
!*v
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_thinking_level: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub verbose: bool,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "hideThinkingBlock"
)]
pub hide_thinking: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "collapseToolOutput"
)]
pub collapse_tool_output: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "autoCompact"
)]
pub auto_compact: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "compactReserveTokens"
)]
pub compact_reserve_tokens: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "compactKeepRecentTokens"
)]
pub compact_keep_recent_tokens: Option<u64>,
#[serde(skip)]
pub(crate) modified_fields: HashSet<String>,
}
impl Settings {
pub fn set_hide_thinking(&mut self, value: Option<bool>) {
self.hide_thinking = value;
self.modified_fields.insert("hideThinkingBlock".into());
}
pub fn set_collapse_tool_output(&mut self, value: Option<bool>) {
self.collapse_tool_output = value;
self.modified_fields.insert("collapseToolOutput".into());
}
pub fn set_default_thinking_level(&mut self, value: Option<String>) {
self.default_thinking_level = value;
self.modified_fields.insert("defaultThinkingLevel".into());
}
pub fn set_auto_compact(&mut self, value: Option<bool>) {
self.auto_compact = value;
self.modified_fields.insert("autoCompact".into());
}
pub fn set_compact_reserve_tokens(&mut self, value: Option<u64>) {
self.compact_reserve_tokens = value;
self.modified_fields.insert("compactReserveTokens".into());
}
pub fn set_compact_keep_recent_tokens(&mut self, value: Option<u64>) {
self.compact_keep_recent_tokens = value;
self.modified_fields
.insert("compactKeepRecentTokens".into());
}
#[doc(hidden)]
pub fn mark_modified(&mut self, field: &str) {
self.modified_fields.insert(field.to_string());
}
pub fn load(cwd: &std::path::Path) -> anyhow::Result<Self> {
let global_path = Self::global_path()?;
Self::load_from(global_path, cwd)
}
pub fn load_from(
global_path: std::path::PathBuf,
cwd: &std::path::Path,
) -> anyhow::Result<Self> {
let global = Self::load_file(&global_path)?;
let project = Self::load_file(&cwd.join(".rab").join("settings.json")).unwrap_or_default();
Ok(Self::merge(global, project))
}
fn global_path() -> anyhow::Result<PathBuf> {
let dir = directories::BaseDirs::new().context("Could not determine home directory")?;
Ok(dir
.home_dir()
.join(".rab")
.join("agent")
.join("settings.json"))
}
fn load_file(path: &std::path::Path) -> anyhow::Result<Settings> {
if !path.exists() {
return Ok(Settings::default());
}
let content = read_file_with_shared_lock(path)?;
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))
}
fn merge(global: Settings, project: Settings) -> Self {
Self {
default_provider: project.default_provider.or(global.default_provider),
default_model: project.default_model.or(global.default_model),
default_thinking_level: project
.default_thinking_level
.or(global.default_thinking_level),
tools: if project.tools.is_empty() {
global.tools
} else {
project.tools
},
exclude_tools: if project.exclude_tools.is_empty() {
global.exclude_tools
} else {
project.exclude_tools
},
theme: project.theme.or(global.theme),
verbose: project.verbose || global.verbose,
hide_thinking: project.hide_thinking.or(global.hide_thinking),
collapse_tool_output: project.collapse_tool_output.or(global.collapse_tool_output),
auto_compact: project.auto_compact.or(global.auto_compact),
compact_reserve_tokens: project
.compact_reserve_tokens
.or(global.compact_reserve_tokens),
compact_keep_recent_tokens: project
.compact_keep_recent_tokens
.or(global.compact_keep_recent_tokens),
modified_fields: HashSet::new(),
}
}
pub fn save(&mut self) -> anyhow::Result<()> {
if self.modified_fields.is_empty() {
return Ok(());
}
let path = Self::global_path()?;
self.save_to(path)
}
pub fn save_to(&mut self, path: std::path::PathBuf) -> anyhow::Result<()> {
if self.modified_fields.is_empty() {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let self_value = serde_json::to_value(&*self)
.with_context(|| format!("Failed to serialize settings to {}", path.display()))?;
let content = compute_merged_content(&path, &self_value, &self.modified_fields)?;
atomic_write_with_lock(&path, &content)?;
self.modified_fields.clear();
Ok(())
}
pub fn reload(&mut self, cwd: &std::path::Path) -> anyhow::Result<()> {
let global_path = Self::global_path()?;
let global = Self::load_file(&global_path)?;
let project = Self::load_file(&cwd.join(".rab").join("settings.json")).unwrap_or_default();
let merged = Self::merge(global, project);
self.default_provider = merged.default_provider;
self.default_model = merged.default_model;
self.default_thinking_level = merged.default_thinking_level;
self.tools = merged.tools;
self.exclude_tools = merged.exclude_tools;
self.theme = merged.theme;
self.verbose = merged.verbose;
self.hide_thinking = merged.hide_thinking;
self.collapse_tool_output = merged.collapse_tool_output;
self.auto_compact = merged.auto_compact;
self.compact_reserve_tokens = merged.compact_reserve_tokens;
self.compact_keep_recent_tokens = merged.compact_keep_recent_tokens;
self.modified_fields.clear();
Ok(())
}
pub fn model(&self) -> &str {
self.default_model.as_deref().unwrap_or("deepseek-v4-flash")
}
}
fn read_file_with_shared_lock(path: &std::path::Path) -> anyhow::Result<String> {
let lock_path = path.with_extension("json.lock");
if let Ok(lock_file) = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
{
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
unsafe {
libc::flock(lock_file.as_raw_fd(), libc::LOCK_SH);
}
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
unsafe {
libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
}
}
Ok(content)
} else {
std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
}
}
fn compute_merged_content(
path: &std::path::Path,
self_value: &serde_json::Value,
modified_fields: &HashSet<String>,
) -> anyhow::Result<String> {
let mut current: serde_json::Value = if path.exists() {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&content).unwrap_or(serde_json::Value::Object(serde_json::Map::new()))
} else {
serde_json::Value::Object(serde_json::Map::new())
};
if let (Some(current_obj), Some(self_obj)) = (current.as_object_mut(), self_value.as_object()) {
for key in modified_fields {
if let Some(value) = self_obj.get(key) {
current_obj.insert(key.clone(), value.clone());
} else {
current_obj.remove(key);
}
}
}
serde_json::to_string_pretty(¤t)
.with_context(|| format!("Failed to serialize settings to {}", path.display()))
}
fn atomic_write_with_lock(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let lock_path = path.with_extension("json.lock");
let lock_file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("Failed to open lock file {}", lock_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } != 0 {
let err = std::io::Error::last_os_error();
anyhow::bail!("Failed to lock {}: {}", lock_path.display(), err);
}
}
let tmp_path = path.with_extension("json.tmp");
std::fs::write(&tmp_path, content)
.with_context(|| format!("Failed to write {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, path).with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
if let Some(parent) = path.parent()
&& let Ok(f) = std::fs::File::open(parent)
{
let _ = f.sync_all();
}
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
unsafe {
libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmp_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("rab_settings_test_{}", name))
}
fn cleanup(path: &PathBuf) {
let _ = fs::remove_file(path);
let _ = fs::remove_file(path.with_extension("json.lock"));
let _ = fs::remove_file(path.with_extension("json.tmp"));
}
#[test]
fn test_save_and_load_roundtrip() {
let path = tmp_path("roundtrip.json");
cleanup(&path);
let mut settings = Settings::default();
settings.set_default_thinking_level(Some("high".into()));
assert_eq!(settings.modified_fields.len(), 1);
assert!(settings.modified_fields.contains("defaultThinkingLevel"));
settings.save_to(path.clone()).unwrap();
assert!(
settings.modified_fields.is_empty(),
"modified_fields should be cleared after save"
);
let content = fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["defaultThinkingLevel"], "high");
let loaded = Settings::load_file(&path).unwrap();
assert_eq!(loaded.default_thinking_level.as_deref(), Some("high"));
cleanup(&path);
}
#[test]
fn test_save_multiple_fields_then_load() {
let path = tmp_path("multi.json");
cleanup(&path);
let mut settings = Settings::default();
settings.set_hide_thinking(Some(true));
settings.set_collapse_tool_output(Some(false));
settings.set_default_thinking_level(Some("medium".into()));
assert_eq!(settings.modified_fields.len(), 3);
settings.save_to(path.clone()).unwrap();
let loaded = Settings::load_file(&path).unwrap();
assert_eq!(loaded.hide_thinking, Some(true));
assert_eq!(loaded.collapse_tool_output, Some(false));
assert_eq!(loaded.default_thinking_level.as_deref(), Some("medium"));
cleanup(&path);
}
#[test]
fn test_incremental_save_preserves_existing_fields() {
let path = tmp_path("incremental.json");
cleanup(&path);
let mut s = Settings::default();
s.set_hide_thinking(Some(false));
s.save_to(path.clone()).unwrap();
let mut s2 = Settings::load_file(&path).unwrap();
assert_eq!(s2.hide_thinking, Some(false));
s2.set_default_thinking_level(Some("low".into()));
s2.save_to(path.clone()).unwrap();
let content = fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["hideThinkingBlock"], false);
assert_eq!(json["defaultThinkingLevel"], "low");
let loaded = Settings::load_file(&path).unwrap();
assert_eq!(loaded.hide_thinking, Some(false));
assert_eq!(loaded.default_thinking_level.as_deref(), Some("low"));
cleanup(&path);
}
#[test]
fn test_unset_field_removed_from_file() {
let path = tmp_path("unset.json");
cleanup(&path);
let mut s = Settings::default();
s.set_default_thinking_level(Some("high".into()));
s.save_to(path.clone()).unwrap();
let mut s2 = Settings::load_file(&path).unwrap();
s2.set_default_thinking_level(None);
s2.save_to(path.clone()).unwrap();
let content = fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(
!json
.as_object()
.unwrap()
.contains_key("defaultThinkingLevel"),
"Field should be removed when set to None"
);
let loaded = Settings::load_file(&path).unwrap();
assert!(loaded.default_thinking_level.is_none());
cleanup(&path);
}
#[test]
fn test_hide_thinking_roundtrip() {
let path = tmp_path("hide.json");
cleanup(&path);
let mut s = Settings::default();
s.set_hide_thinking(Some(false));
s.save_to(path.clone()).unwrap();
let loaded = Settings::load_file(&path).unwrap();
assert_eq!(loaded.hide_thinking, Some(false));
let mut s2 = Settings::load_file(&path).unwrap();
s2.set_hide_thinking(Some(true));
s2.save_to(path.clone()).unwrap();
let loaded2 = Settings::load_file(&path).unwrap();
assert_eq!(loaded2.hide_thinking, Some(true));
cleanup(&path);
}
#[test]
fn test_merge_global_and_project() {
let mut global = Settings::default();
global.hide_thinking = Some(true);
global.default_thinking_level = Some("high".into());
let mut project = Settings::default();
project.hide_thinking = Some(false);
let merged = Settings::merge(global, project);
assert_eq!(merged.hide_thinking, Some(false));
assert_eq!(merged.default_thinking_level.as_deref(), Some("high"));
assert!(merged.modified_fields.is_empty());
}
#[test]
fn test_save_only_modified_fields() {
let path = tmp_path("modified_only.json");
cleanup(&path);
let initial = serde_json::json!({
"theme": "dark",
"defaultModel": "claude-sonnet",
"hideThinkingBlock": true
});
fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
let mut s = Settings::load_file(&path).unwrap();
assert_eq!(s.hide_thinking, Some(true));
assert_eq!(s.theme.as_deref(), Some("dark"));
assert_eq!(s.model(), "claude-sonnet");
s.set_default_thinking_level(Some("low".into()));
s.save_to(path.clone()).unwrap();
let content = fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
json["hideThinkingBlock"], true,
"hideThinkingBlock preserved"
);
assert_eq!(
json["defaultThinkingLevel"], "low",
"defaultThinkingLevel added"
);
cleanup(&path);
}
#[test]
fn test_clear_modified_fields_only_after_write() {
let path = tmp_path("clear_modified.json");
cleanup(&path);
let mut s = Settings::default();
s.set_default_thinking_level(Some("xhigh".into()));
s.set_hide_thinking(Some(false));
s.save_to(path.clone()).unwrap();
assert!(s.modified_fields.is_empty());
s.set_hide_thinking(Some(true));
assert_eq!(s.modified_fields.len(), 1);
assert!(s.modified_fields.contains("hideThinkingBlock"));
s.save_to(path.clone()).unwrap();
assert!(s.modified_fields.is_empty());
cleanup(&path);
}
#[test]
fn test_lock_file_created_and_lock_released() {
let path = tmp_path("lock_test.json");
cleanup(&path);
let mut s = Settings::default();
s.set_default_thinking_level(Some("high".into()));
s.save_to(path.clone()).unwrap();
let lock_path = path.with_extension("json.lock");
assert!(lock_path.exists(), "Lock file should exist after write");
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let lock_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let result =
unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
assert_eq!(result, 0, "Lock must be released after write");
unsafe {
libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
}
}
cleanup(&path);
}
#[test]
fn test_full_persistence_cycle() {
let path = tmp_path("full_cycle.json");
cleanup(&path);
{
let mut settings = Settings::default();
settings.set_hide_thinking(Some(false));
settings.set_default_thinking_level(Some("xhigh".into()));
settings.save_to(path.clone()).unwrap();
}
{
let loaded = Settings::load_file(&path).unwrap();
assert_eq!(loaded.hide_thinking, Some(false), "hide_thinking persists");
assert_eq!(
loaded.default_thinking_level.as_deref(),
Some("xhigh"),
"thinking level persists"
);
}
{
let mut settings = Settings::load_file(&path).unwrap();
settings.set_hide_thinking(Some(true));
settings.set_default_thinking_level(Some("low".into()));
settings.save_to(path.clone()).unwrap();
}
{
let loaded = Settings::load_file(&path).unwrap();
assert_eq!(loaded.hide_thinking, Some(true), "hide_thinking updated");
assert_eq!(
loaded.default_thinking_level.as_deref(),
Some("low"),
"thinking level updated"
);
}
{
let lock_path = path.with_extension("json.lock");
assert!(lock_path.exists(), "Lock file should exist");
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let lock_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let result =
unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
assert_eq!(result, 0, "Lock must be released after save");
unsafe {
libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN);
}
}
}
cleanup(&path);
}
#[test]
fn test_concurrent_writes_to_same_file() {
let path = tmp_path("concurrent.json");
cleanup(&path);
let mut s1 = Settings::default();
s1.set_hide_thinking(Some(true));
s1.set_default_thinking_level(Some("xhigh".into()));
let mut s2 = Settings::default();
s2.set_hide_thinking(Some(false));
s2.set_default_thinking_level(Some("low".into()));
s1.save_to(path.clone()).unwrap();
s2.save_to(path.clone()).unwrap();
let content = fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(json.is_object(), "File must be valid JSON, not corrupted");
assert_eq!(json["hideThinkingBlock"], false, "s2's hide_thinking");
assert_eq!(json["defaultThinkingLevel"], "low", "s2's thinking level");
cleanup(&path);
}
#[test]
fn test_lock_file_cleanup() {
let path = tmp_path("lock_cleanup.json");
cleanup(&path);
let mut s = Settings::default();
s.set_hide_thinking(Some(true));
s.save_to(path.clone()).unwrap();
let lock_path = path.with_extension("json.lock");
assert!(lock_path.exists(), "Lock file should exist");
let tmp_path = path.with_extension("json.tmp");
assert!(!tmp_path.exists(), "Temp file should be removed");
cleanup(&path);
}
#[test]
fn test_reload_preserves_unmodified() {
let path = tmp_path("reload_preserve.json");
cleanup(&path);
let initial = serde_json::json!({
"theme": "solarized",
"defaultModel": "deepseek-v4-pro",
"hideThinkingBlock": true
});
fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
let mut s = Settings::load_file(&path).unwrap();
s.set_default_thinking_level(Some("high".into()));
s.save_to(path.clone()).unwrap();
let content = fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["theme"], "solarized", "theme preserved");
assert_eq!(json["defaultModel"], "deepseek-v4-pro", "model preserved");
assert_eq!(
json["hideThinkingBlock"], true,
"hideThinkingBlock preserved"
);
assert_eq!(json["defaultThinkingLevel"], "high", "thinking level added");
cleanup(&path);
}
}