use crate::{
PROJECT_SCOPE_NAME,
core::{
compaction::{DEFAULT_KEEP_RECENT_TOKENS, DEFAULT_RESERVE_TOKENS},
config_cache::ConfigCache,
project_trust,
},
error::{Error, Result},
utils::{
display::random_uuid_v4,
file_lock::FileLock,
mime::strip_bom,
paths::{cwd, expand_home, expand_tilde, home_dir},
wheel::WheelScrollLines,
},
};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
sync::{
Mutex, OnceLock, RwLock,
atomic::{AtomicBool, Ordering},
},
};
use strum_macros::{EnumString, IntoStaticStr};
pub const HISTORY_MAX_ENTRIES_DEFAULT: usize = 500;
pub const HISTORY_MAX_ENTRIES_TIERS: [usize; 5] = [0, 100, 250, 500, 1000];
pub const DEFAULT_TOOL_NAMES: [&str; 4] = ["read", "bash", "edit", "write"];
pub const RETRY_MAX_RETRIES_TIERS: [u32; 6] = [0, 1, 2, 3, 5, 10];
pub const COMPACT_RESERVE_TIERS: [u32; 5] = [4096, 8192, 16384, 32768, 65536];
pub const COMPACT_KEEP_RECENT_TIERS: [u32; 5] = [8000, 12000, 20000, 40000, 80000];
pub const TEMPERATURE_TIERS: [&str; 6] = ["default", "0", "0.2", "0.5", "0.7", "1"];
pub const SETTINGS_SCHEMA_URL: &str = "https://pi.dev/schemas/settings.schema.json";
static SETTINGS_LOCK: RwLock<()> = RwLock::new(());
static INTERACTIVE_SETTINGS_UI: AtomicBool = AtomicBool::new(false);
static PENDING_SETTINGS_OVERWRITE: OnceLock<Mutex<Option<PendingSettingsOverwrite>>> =
OnceLock::new();
static GLOBAL_SETTINGS_CACHE: ConfigCache = ConfigCache::new();
static PROJECT_SETTINGS_CACHE: ConfigCache = ConfigCache::new();
fn with_settings_read<R>(f: impl FnOnce() -> R) -> R {
let _guard = SETTINGS_LOCK.read().unwrap_or_else(|e| e.into_inner());
f()
}
fn with_settings_write<R>(f: impl FnOnce() -> R) -> R {
let _guard = SETTINGS_LOCK.write().unwrap_or_else(|e| e.into_inner());
f()
}
fn acquire_settings_file_lock(path: &Path) -> Result<FileLock> {
FileLock::acquire(path).map_err(|e| Error::msg(e.to_string()))
}
#[derive(Debug, Clone)]
struct PendingSettingsOverwrite {
path: PathBuf,
value: Value,
detail: String,
}
fn pending_settings_overwrite_slot() -> &'static Mutex<Option<PendingSettingsOverwrite>> {
PENDING_SETTINGS_OVERWRITE.get_or_init(|| Mutex::new(None))
}
pub fn set_interactive_settings_ui(attached: bool) {
INTERACTIVE_SETTINGS_UI.store(attached, Ordering::Relaxed);
}
fn interactive_settings_ui_attached() -> bool {
#[cfg(any(test, feature = "test-support"))]
if let Some(v) = crate::test_support::settings_ui_override() {
return v;
}
INTERACTIVE_SETTINGS_UI.load(Ordering::Relaxed)
}
fn stage_settings_overwrite(path: PathBuf, value: Value, detail: String) {
let mut guard = pending_settings_overwrite_slot()
.lock()
.unwrap_or_else(|e| e.into_inner());
match guard.as_mut() {
Some(pending) if pending.path == path => {
match (pending.value.as_object_mut(), value.as_object()) {
(Some(dst), Some(src)) => {
for (k, v) in src {
dst.insert(k.clone(), v.clone());
}
}
_ => pending.value = value,
}
pending.detail = detail;
}
_ => {
*guard = Some(PendingSettingsOverwrite {
path,
value,
detail,
});
}
}
}
pub fn pending_settings_overwrite() -> Option<(String, String)> {
pending_settings_overwrite_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.map(|p| (p.path.display().to_string(), p.detail.clone()))
}
pub fn confirm_settings_overwrite() -> Result<()> {
with_settings_write(|| {
let Some(pending) = pending_settings_overwrite_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
else {
return Ok(());
};
let _file_lock = acquire_settings_file_lock(&pending.path)?;
write_settings_unchecked(pending.value, pending.path)
})
}
pub fn cancel_settings_overwrite() {
*pending_settings_overwrite_slot()
.lock()
.unwrap_or_else(|e| e.into_inner()) = None;
}
fn settings_value_for_write(path: &Path) -> Value {
if settings_file_problem(path).is_none() {
return GLOBAL_SETTINGS_CACHE.read(path, || read_settings_file(path));
}
let guard = pending_settings_overwrite_slot()
.lock()
.unwrap_or_else(|e| e.into_inner());
match guard.as_ref().filter(|p| p.path.as_path() == path) {
Some(pending) => pending.value.clone(),
None => {
drop(guard);
GLOBAL_SETTINGS_CACHE.read(path, || read_settings_file(path))
}
}
}
fn clear_pending_settings_overwrite(path: &Path) {
let mut guard = pending_settings_overwrite_slot()
.lock()
.unwrap_or_else(|e| e.into_inner());
if guard.as_ref().is_some_and(|p| p.path.as_path() == path) {
*guard = None;
}
}
fn modify_global_settings<F>(f: F) -> Result<()>
where
F: FnOnce(&mut serde_json::Map<String, Value>),
{
with_settings_write(|| {
let path = agent_dir().join("settings.json");
let _file_lock = acquire_settings_file_lock(&path)?;
let mut v = settings_value_for_write(&path);
if !v.is_object() {
v = json!({});
}
let obj = v
.as_object_mut()
.ok_or_else(|| Error::msg("settings.json is not an object"))?;
f(obj);
ensure_settings_schema(obj);
save_settings(v, path)
})
}
fn ensure_settings_schema(obj: &mut serde_json::Map<String, Value>) {
obj.entry("$schema".to_string())
.or_insert_with(|| json!(SETTINGS_SCHEMA_URL));
}
pub fn agent_dir() -> PathBuf {
#[cfg(any(test, feature = "test-support"))]
{
if let Some(dir) = crate::test_support::agent_dir_override() {
return dir;
}
if let Ok(dir) = std::env::var("PRUX_AGENT_DIR")
&& !dir.is_empty()
{
return expand_tilde(PathBuf::from(dir));
}
test_agent_dir_fallback()
}
#[cfg(not(any(test, feature = "test-support")))]
{
if let Ok(dir) = std::env::var("PRUX_AGENT_DIR") {
return expand_tilde(PathBuf::from(dir));
}
default_agent_dir()
}
}
#[cfg(any(test, feature = "test-support"))]
fn test_agent_dir_fallback() -> PathBuf {
let dir = std::env::temp_dir().join("prux-auth-tests");
let _ = std::fs::create_dir_all(&dir);
dir
}
#[cfg_attr(any(test, feature = "test-support"), allow(dead_code))]
fn default_agent_dir() -> PathBuf {
home_dir().join(PROJECT_SCOPE_NAME)
}
#[derive(Debug, Clone)]
pub struct Settings {
pub default_model: Option<String>,
pub default_provider: Option<String>,
pub default_thinking_level: Option<String>,
pub default_project_trust: Option<String>,
pub theme: Option<String>,
pub external_editor: Option<String>,
pub skills: Vec<String>,
pub themes: Vec<String>,
pub enabled_models: Vec<String>,
}
fn string_array(v: &Value, key: &str) -> Vec<String> {
v.get(key)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn read_settings_file(path: &Path) -> Value {
let Ok(text) = std::fs::read_to_string(path) else {
return json!({});
};
let text = strip_bom(&text);
if text.trim().is_empty() {
return json!({});
}
match serde_json::from_str::<Value>(text) {
Ok(v) if v.is_object() => v,
Ok(_) => json!({}),
Err(_) => Value::Object(salvage_settings_object(text)),
}
}
fn salvage_settings_object(text: &str) -> serde_json::Map<String, Value> {
let mut out = serde_json::Map::new();
let bytes = text.as_bytes();
let mut i = 0usize;
let mut depth = 0i32;
while i < bytes.len() {
match bytes[i] {
b'"' => {
let Some((s, end)) = parse_json_token(text, i) else {
i += 1;
continue;
};
let key = match s {
Value::String(s) if depth == 1 => Some(s),
_ => None,
};
let Some(key) = key else {
i = end;
continue;
};
let mut j = skip_ws(bytes, end);
if bytes.get(j) != Some(&b':') {
i = end;
continue;
}
j = skip_ws(bytes, j + 1);
match parse_json_token(text, j) {
Some((value, after)) => {
out.insert(key, value);
i = after;
}
None => i = j,
}
}
b'{' | b'[' => {
depth += 1;
i += 1;
}
b'}' | b']' => {
depth -= 1;
i += 1;
}
_ => i += 1,
}
}
out
}
fn parse_json_token(text: &str, start: usize) -> Option<(Value, usize)> {
let de = serde_json::Deserializer::from_str(text.get(start..)?);
let mut stream = de.into_iter::<Value>();
match stream.next()? {
Ok(v) => Some((v, start + stream.byte_offset())),
Err(_) => None,
}
}
fn skip_ws(bytes: &[u8], mut i: usize) -> usize {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
i
}
fn settings_value() -> Value {
let path = agent_dir().join("settings.json");
with_settings_read(|| GLOBAL_SETTINGS_CACHE.read(&path, || read_settings_file(&path)))
}
pub fn settings_section(key: &str) -> Value {
let global = settings_value().get(key).cloned().unwrap_or(Value::Null);
let project = project_settings_value()
.get(key)
.cloned()
.unwrap_or(Value::Null);
let mut merged = global.as_object().cloned().unwrap_or_default();
if let Some(project) = project.as_object() {
for (k, v) in project {
merged.insert(k.clone(), v.clone());
}
}
if merged.is_empty() {
Value::Null
} else {
Value::Object(merged)
}
}
fn project_settings_value() -> Value {
let cwd = cwd();
if !project_trust::is_project_trusted(&cwd, &agent_dir()) {
return Value::Null;
}
let path = cwd.join(PROJECT_SCOPE_NAME).join("settings.json");
with_settings_read(|| PROJECT_SETTINGS_CACHE.read(&path, || read_settings_file(&path)))
}
pub fn settings_load_error() -> Option<String> {
let path = agent_dir().join("settings.json");
if let Ok(text) = std::fs::read_to_string(&path)
&& let Err(e) = serde_json::from_str::<Value>(strip_bom(&text))
{
return Some(format!("Invalid settings file {}: {}", path.display(), e));
}
if let Ok(cwd) = std::env::current_dir() {
let p = cwd.join(PROJECT_SCOPE_NAME).join("settings.json");
if let Ok(t) = std::fs::read_to_string(&p)
&& let Err(e) = serde_json::from_str::<Value>(strip_bom(&t))
{
return Some(format!("Invalid settings file {}: {}", p.display(), e));
}
}
None
}
pub fn is_tool_modifier(entry: &str) -> bool {
entry.starts_with('+') || entry.starts_with('-')
}
pub fn get_tool_list_error(entries: &[String]) -> Option<String> {
let modifiers = entries.iter().filter(|e| is_tool_modifier(e)).count();
if modifiers == 0 {
return None;
}
if modifiers < entries.len() {
return Some("tool names cannot be mixed with +name or -name entries".to_string());
}
entries.iter().find(|e| e.contains('*')).map(|pattern| {
format!("+name and -name entries take exact tool names, not patterns: {pattern}")
})
}
pub fn apply_tool_modifiers(base: &[String], entries: &[String]) -> Vec<String> {
let mut tools = base.to_vec();
for entry in entries {
if !is_tool_modifier(entry) {
continue;
}
let name = &entry[1..];
let index = tools.iter().position(|t| t == name);
if entry.starts_with('+') {
if index.is_none() && !name.is_empty() {
tools.push(name.to_string());
}
} else if let Some(index) = index {
tools.remove(index);
}
}
tools
}
fn merge_default_tools(
base: Option<&[String]>,
overrides: Option<&[String]>,
) -> Option<Vec<String>> {
let Some(overrides) = overrides else {
return base.map(|b| b.to_vec());
};
let Some(base) = base else {
return Some(overrides.to_vec());
};
if !overrides.iter().all(|e| is_tool_modifier(e)) {
return Some(overrides.to_vec());
}
let mut out = base.to_vec();
out.extend(overrides.iter().cloned());
Some(out)
}
fn resolve_default_tools(entries: &[String]) -> Vec<String> {
let plain: Vec<String> = entries
.iter()
.filter(|e| !is_tool_modifier(e))
.cloned()
.collect();
let base: Vec<String> = if !plain.is_empty() || entries.is_empty() {
plain
} else {
DEFAULT_TOOL_NAMES.iter().map(|s| s.to_string()).collect()
};
apply_tool_modifiers(&base, entries)
}
fn default_tools_of(v: &Value) -> Option<Vec<String>> {
v.get("defaultTools")
.and_then(|v| v.as_array())
.map(|_| string_array(v, "defaultTools"))
}
pub fn read_settings_default_tools() -> Option<Vec<String>> {
let global = default_tools_of(&settings_value());
let project = default_tools_of(&project_settings_value());
let merged = merge_default_tools(global.as_deref(), project.as_deref())?;
Some(resolve_default_tools(&merged))
}
pub fn write_settings_default_tools(entries: Option<&[String]>) -> Result<()> {
modify_global_settings(|obj| match entries {
Some(list) if !list.is_empty() => {
obj.insert("defaultTools".to_string(), json!(list));
}
_ => {
obj.remove("defaultTools");
}
})
}
pub fn toggle_settings_default_tool(name: &str, enabled_now: bool) -> Result<Vec<String>> {
let plus = format!("+{name}");
let minus = format!("-{name}");
let mut result: Vec<String> = Vec::new();
modify_global_settings(|obj| {
let explicit = obj.get("defaultTools").and_then(|v| v.as_array()).is_some();
let mut entries: Vec<String> = obj
.get("defaultTools")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
if explicit && entries.is_empty() {
let mut tools = resolve_default_tools(&entries);
if !enabled_now && !tools.iter().any(|t| t == name) {
tools.push(name.to_string());
}
entries = tools;
} else if enabled_now {
if entries.contains(&plus) {
entries.retain(|e| *e != plus);
} else {
let plain = entries.iter().any(|e| *e == name);
let baseline = entries.iter().all(|e| is_tool_modifier(e))
&& DEFAULT_TOOL_NAMES.contains(&name);
if plain || baseline {
entries.push(minus);
}
}
} else if entries.contains(&minus) {
entries.retain(|e| *e != minus);
} else {
let plain = entries.iter().any(|e| *e == name);
let baseline =
entries.iter().all(|e| is_tool_modifier(e)) && DEFAULT_TOOL_NAMES.contains(&name);
if !plain && !baseline {
entries.push(plus);
}
}
if entries.is_empty() {
obj.remove("defaultTools");
} else {
obj.insert("defaultTools".to_string(), json!(entries));
}
result = entries;
})?;
Ok(result)
}
pub fn read_settings() -> Settings {
let v = settings_value();
Settings {
default_model: v
.get("defaultModel")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
default_provider: v
.get("defaultProvider")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
default_thinking_level: v
.get("defaultThinkingLevel")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
default_project_trust: v
.get("defaultProjectTrust")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
theme: v
.get("theme")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
external_editor: v
.get("externalEditor")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
skills: string_array(&v, "skills"),
themes: string_array(&v, "themes"),
enabled_models: string_array(&v, "enabledModels"),
}
}
pub fn read_settings_temperature() -> Option<f64> {
settings_value().get("temperature").and_then(|v| v.as_f64())
}
pub fn read_settings_external_editor() -> Option<String> {
settings_value()
.get("externalEditor")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
pub fn read_disabled_extensions() -> Vec<String> {
merge_extension_names(
string_array(&settings_value(), "disabledExtensions"),
string_array(&project_settings_value(), "disabledExtensions"),
)
}
pub fn read_enabled_extensions() -> Vec<String> {
merge_extension_names(
string_array(&settings_value(), "enabledExtensions"),
string_array(&project_settings_value(), "enabledExtensions"),
)
}
fn merge_extension_names(mut global: Vec<String>, project: Vec<String>) -> Vec<String> {
for name in project {
if !global.contains(&name) {
global.push(name);
}
}
global
}
pub fn read_extension_mode() -> String {
settings_value()
.get("extensionMode")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "all".to_string())
}
pub fn write_extension_mode(mode: &str) -> Result<()> {
modify_global_settings(|obj| {
obj.insert("extensionMode".to_string(), serde_json::json!(mode));
})
}
pub fn write_default_model_and_provider(provider: &str, model_id: &str) -> Result<()> {
modify_global_settings(|obj| {
obj.insert("defaultProvider".to_string(), serde_json::json!(provider));
obj.insert("defaultModel".to_string(), serde_json::json!(model_id));
})
}
pub fn read_enabled_models() -> Vec<String> {
string_array(&settings_value(), "enabledModels")
}
pub fn read_settings_skills() -> Vec<String> {
string_array(&settings_value(), "skills")
}
pub fn write_settings_skills(rules: &[String]) -> Result<()> {
modify_global_settings(|obj| {
if rules.is_empty() {
obj.remove("skills");
} else {
obj.insert("skills".to_string(), serde_json::json!(rules));
}
})
}
pub fn write_enabled_models(patterns: Option<&[String]>) -> Result<()> {
modify_global_settings(|obj| match patterns {
Some(pats) if !pats.is_empty() => {
obj.insert("enabledModels".to_string(), serde_json::json!(pats));
}
_ => {
obj.remove("enabledModels");
}
})
}
pub fn write_theme(name: &str) -> Result<()> {
modify_global_settings(|obj| {
obj.insert("theme".to_string(), serde_json::json!(name));
})
}
pub fn write_disabled_extensions(disabled: &[String]) -> Result<()> {
modify_global_settings(|obj| {
obj.insert(
"disabledExtensions".to_string(),
serde_json::json!(disabled),
);
})
}
pub fn write_extension_states(disabled: &[String], enabled: &[String]) -> Result<()> {
modify_global_settings(|obj| {
obj.insert(
"disabledExtensions".to_string(),
serde_json::json!(disabled),
);
obj.insert("enabledExtensions".to_string(), serde_json::json!(enabled));
})
}
pub fn read_settings_device_id() -> Option<String> {
settings_value()
.get("deviceId")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.trim().is_empty())
}
pub fn get_or_create_device_id() -> Result<String> {
if let Some(id) = read_settings_device_id() {
return Ok(id);
}
let id = random_uuid_v4()?;
write_settings_value("deviceId", json!(id))?;
Ok(id)
}
fn write_settings_value(key: &str, value: Value) -> Result<()> {
modify_global_settings(|obj| {
obj.insert(key.to_string(), value);
})
}
pub fn read_settings_auto_compact() -> bool {
settings_value()
.get("autoCompact")
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
pub fn read_settings_compact_reserve() -> u32 {
let v = settings_value();
v.get("compactReserveTokens")
.and_then(|v| v.as_u64())
.or_else(|| {
v.pointer("/compaction/reserveTokens")
.and_then(|v| v.as_u64())
})
.map(|v| v as u32)
.unwrap_or(DEFAULT_RESERVE_TOKENS)
}
pub fn read_settings_compact_keep_recent() -> u32 {
let v = settings_value();
v.get("compactKeepRecentTokens")
.and_then(|v| v.as_u64())
.or_else(|| {
v.pointer("/compaction/keepRecentTokens")
.and_then(|v| v.as_u64())
})
.map(|v| v as u32)
.unwrap_or(DEFAULT_KEEP_RECENT_TOKENS)
}
fn compact_model_override(provider: &str, model_id: &str, field: &str) -> Option<u32> {
let key = format!("{provider}/{model_id}");
settings_value()
.pointer("/compaction/modelOverrides")
.and_then(|v| v.get(key.as_str()))
.and_then(|v| v.get(field))
.and_then(|v| v.as_u64())
.map(|v| v as u32)
}
pub fn read_settings_compact_reserve_for(provider: &str, model_id: &str) -> u32 {
compact_model_override(provider, model_id, "reserveTokens")
.unwrap_or_else(read_settings_compact_reserve)
}
pub fn read_settings_compact_keep_recent_for(provider: &str, model_id: &str) -> u32 {
compact_model_override(provider, model_id, "keepRecentTokens")
.unwrap_or_else(read_settings_compact_keep_recent)
}
pub fn write_settings_expose_session_environment(enabled: bool) -> Result<()> {
write_settings_value("exposeSessionEnvironment", json!(enabled))
}
pub fn write_settings_auto_compact(enabled: bool) -> Result<()> {
write_settings_value("autoCompact", json!(enabled))
}
pub fn read_settings_expose_session_environment() -> bool {
settings_value()
.get("exposeSessionEnvironment")
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
pub fn read_settings_shell_command_prefix() -> String {
settings_value()
.get("shellCommandPrefix")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default()
}
pub fn read_settings_shell_path() -> Option<String> {
settings_value()
.get("shellPath")
.and_then(|v| v.as_str())
.map(expand_home)
.map(|s| s.to_string())
}
pub fn read_settings_retry_enabled() -> bool {
settings_value()
.get("retry")
.and_then(|v| v.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
pub fn read_settings_retry_max_retries() -> u32 {
settings_value()
.get("retry")
.and_then(|v| v.get("maxRetries"))
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.unwrap_or(3)
}
pub fn read_settings_retry_base_delay_ms() -> u64 {
settings_value()
.get("retry")
.and_then(|v| v.get("baseDelayMs"))
.and_then(|v| v.as_u64())
.unwrap_or(2000)
}
pub fn read_settings_images_auto_resize() -> bool {
settings_value()
.get("images")
.and_then(|v| v.get("autoResize"))
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
pub fn write_settings_images_auto_resize(enabled: bool) -> Result<()> {
write_settings_nested("images", "autoResize", json!(enabled))
}
pub fn write_settings_images_block_images(enabled: bool) -> Result<()> {
write_settings_nested("images", "blockImages", json!(enabled))
}
pub fn write_settings_retry_enabled(enabled: bool) -> Result<()> {
write_settings_nested("retry", "enabled", json!(enabled))
}
pub fn write_settings_retry_max_retries(max_retries: u32) -> Result<()> {
write_settings_nested("retry", "maxRetries", json!(max_retries))
}
pub fn write_settings_temperature(value: Option<f64>) -> Result<()> {
modify_global_settings(|obj| match value {
Some(t) => {
obj.insert("temperature".to_string(), json!(t));
}
None => {
obj.remove("temperature");
}
})
}
pub fn write_settings_compact_reserve(tokens: u32) -> Result<()> {
write_settings_value("compactReserveTokens", json!(tokens))
}
pub fn write_settings_compact_keep_recent(tokens: u32) -> Result<()> {
write_settings_value("compactKeepRecentTokens", json!(tokens))
}
fn write_settings_nested(group: &str, key: &str, value: Value) -> Result<()> {
modify_global_settings(|obj| {
let entry = obj.entry(group.to_string()).or_insert_with(|| json!({}));
if !entry.is_object() {
*entry = json!({});
}
if let Some(map) = entry.as_object_mut() {
map.insert(key.to_string(), value);
}
})
}
pub fn read_settings_images_block_images() -> bool {
settings_value()
.get("images")
.and_then(|v| v.get("blockImages"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
pub fn read_settings_cache_warming() -> String {
let value = settings_value();
let raw = value
.get("cacheWarming")
.and_then(|v| v.as_str())
.unwrap_or("");
if matches!(raw, "off" | "streaming" | "idle") {
raw.to_string()
} else {
"off".to_string()
}
}
pub fn write_settings_cache_warming(mode: &str) -> Result<String> {
let normalized = if matches!(mode, "off" | "streaming" | "idle") {
mode
} else {
"off"
};
write_settings_value("cacheWarming", serde_json::json!(normalized))?;
Ok(normalized.to_string())
}
pub fn read_settings_show_cache_miss_notices() -> bool {
settings_value()
.get("showCacheMissNotices")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
pub fn write_settings_show_cache_miss_notices(enabled: bool) -> Result<()> {
write_settings_value("showCacheMissNotices", serde_json::json!(enabled))
}
pub fn read_settings_steering_mode() -> String {
settings_value()
.get("steeringMode")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "one-at-a-time".to_string())
}
pub fn write_settings_steering_mode(mode: &str) -> Result<()> {
write_settings_value("steeringMode", json!(mode))
}
pub fn read_settings_follow_up_mode() -> String {
settings_value()
.get("followUpMode")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "one-at-a-time".to_string())
}
pub fn write_settings_follow_up_mode(mode: &str) -> Result<()> {
write_settings_value("followUpMode", json!(mode))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)]
pub enum SessionShareDestination {
#[strum(serialize = "auto")]
Auto,
#[strum(serialize = "gist")]
Gist,
#[strum(serialize = "radius")]
Radius,
}
impl SessionShareDestination {
pub fn as_str(self) -> &'static str {
self.into()
}
pub fn parse(value: &str) -> Option<SessionShareDestination> {
value.trim().parse().ok()
}
}
pub fn read_settings_session_share_destination() -> SessionShareDestination {
settings_value()
.get("sessionShareDestination")
.and_then(|v| v.as_str())
.and_then(SessionShareDestination::parse)
.unwrap_or(SessionShareDestination::Auto)
}
pub fn write_settings_session_share_destination(
destination: SessionShareDestination,
) -> Result<()> {
write_settings_value("sessionShareDestination", json!(destination.as_str()))
}
pub fn read_settings_hide_thinking() -> bool {
settings_value()
.get("hideThinkingBlock")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
pub fn write_settings_hide_thinking(enabled: bool) -> Result<()> {
write_settings_value("hideThinkingBlock", json!(enabled))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)]
pub enum QuietStartup {
#[strum(serialize = "false")]
Full,
#[strum(serialize = "header")]
Header,
#[strum(serialize = "true")]
Silent,
}
impl QuietStartup {
pub fn shows_banner(self) -> bool {
!matches!(self, QuietStartup::Silent)
}
pub fn shows_details(self) -> bool {
matches!(self, QuietStartup::Full)
}
pub fn as_str(self) -> &'static str {
self.into()
}
pub fn parse(value: &str) -> Option<QuietStartup> {
value.trim().parse().ok()
}
}
pub fn read_settings_quiet_startup() -> QuietStartup {
settings_value()
.get("quietStartup")
.and_then(|v| match v {
Value::Bool(true) => Some(QuietStartup::Silent),
Value::Bool(false) => Some(QuietStartup::Full),
Value::String(s) => QuietStartup::parse(s),
_ => None,
})
.unwrap_or(QuietStartup::Header)
}
pub fn write_settings_quiet_startup(level: QuietStartup) -> Result<()> {
let value = match level {
QuietStartup::Full => json!(false),
QuietStartup::Header => json!("header"),
QuietStartup::Silent => json!(true),
};
write_settings_value("quietStartup", value)
}
pub fn read_settings_syntax_highlight() -> bool {
settings_value()
.get("syntaxHighlight")
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
pub fn write_settings_syntax_highlight(enabled: bool) -> Result<()> {
write_settings_value("syntaxHighlight", json!(enabled))
}
pub fn read_settings_mermaid() -> bool {
settings_value()
.get("mermaid")
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
pub fn write_settings_mermaid(enabled: bool) -> Result<()> {
write_settings_value("mermaid", json!(enabled))
}
pub fn read_settings_show_images() -> bool {
settings_value()
.get("showImages")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
pub fn write_settings_show_images(enabled: bool) -> Result<()> {
write_settings_value("showImages", json!(enabled))
}
pub fn read_settings_latex() -> bool {
settings_value()
.get("latex")
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
pub fn write_settings_latex(enabled: bool) -> Result<()> {
write_settings_value("latex", json!(enabled))
}
pub fn read_settings_autocomplete_max_visible() -> usize {
settings_value()
.get("autocompleteMaxVisible")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.filter(|n| *n > 0)
.unwrap_or(5)
}
pub fn write_settings_autocomplete_max_visible(n: usize) -> Result<()> {
write_settings_value("autocompleteMaxVisible", json!(n))
}
pub fn read_settings_fullscreen_wheel_scroll_lines() -> WheelScrollLines {
WheelScrollLines::from_json(settings_value().get("fullscreenWheelScrollLines"))
}
pub fn write_settings_fullscreen_wheel_scroll_lines(lines: WheelScrollLines) -> Result<()> {
let value = match lines {
WheelScrollLines::Auto => json!("auto"),
WheelScrollLines::Lines(n) => json!(n.clamp(1, WheelScrollLines::MAX)),
};
write_settings_value("fullscreenWheelScrollLines", value)
}
pub fn read_settings_history_max_entries() -> usize {
settings_value()
.get("historyMaxEntries")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(HISTORY_MAX_ENTRIES_DEFAULT)
}
pub fn write_settings_history_max_entries(n: usize) -> Result<()> {
write_settings_value("historyMaxEntries", json!(n))
}
pub fn write_settings_default_project_trust(trust: &str) -> Result<()> {
write_settings_value("defaultProjectTrust", json!(trust))
}
pub fn write_settings_default_thinking_level(level: &str) -> Result<()> {
write_settings_value("defaultThinkingLevel", json!(level))
}
pub fn read_skip_update_version() -> Option<String> {
settings_value()
.get("skipUpdateVersion")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
pub fn write_skip_update_version(version: &str) -> Result<()> {
write_settings_value("skipUpdateVersion", json!(version))
}
fn settings_file_problem(path: &Path) -> Option<String> {
let existing = std::fs::read_to_string(path).ok()?;
let text = strip_bom(&existing);
if text.trim().is_empty() {
return None;
}
match serde_json::from_str::<Value>(text) {
Ok(Value::Object(_)) => None,
Ok(_) => Some(format!("{} is not a JSON object", path.display())),
Err(e) => Some(format!("{} is not valid JSON ({})", path.display(), e)),
}
}
fn set_file_mode(path: &Path, mode: Option<u32>) {
#[cfg(unix)]
if let Some(mode) = mode {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).ok();
}
#[cfg(not(unix))]
let _ = (path, mode);
}
pub(crate) fn atomic_write_config(path: &Path, bytes: &[u8], mode: Option<u32>) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|source| Error::Io {
context: format!("Failed to create dir: {}", parent.display()),
source,
})?;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("config");
let tmp = path.with_file_name(format!("{}.{}.tmp", name, std::process::id()));
let result = (|| -> std::io::Result<()> {
std::fs::write(&tmp, bytes)?;
set_file_mode(&tmp, mode);
std::fs::rename(&tmp, path)
})();
match result {
Ok(()) => Ok(()),
Err(source) => {
_ = std::fs::remove_file(&tmp);
Err(Error::Io {
context: format!("Failed to write {}", path.display()),
source,
})
}
}
}
fn write_settings_unchecked(v: Value, path: PathBuf) -> Result<()> {
let text = serde_json::to_string_pretty(&v).map_err(|source| Error::Json {
context: "Failed to serialize settings.json".to_string(),
source,
})?;
atomic_write_config(&path, text.as_bytes(), None)?;
GLOBAL_SETTINGS_CACHE.store(&path, v);
Ok(())
}
fn save_settings(v: Value, path: PathBuf) -> Result<()> {
if let Some(detail) = settings_file_problem(&path) {
if interactive_settings_ui_attached() {
stage_settings_overwrite(path, v, detail);
return Ok(());
}
return Err(Error::msg(format!(
"{}; refusing to overwrite it. Fix or remove the file and retry.",
detail
)));
}
clear_pending_settings_overwrite(&path);
write_settings_unchecked(v, path)
}
#[cfg(test)]
mod tests {
use super::*;
fn with_temp_agent_dir(f: impl FnOnce()) {
let _ad = crate::test_support::AgentDirGuard::temp();
f();
}
#[test]
fn default_agent_dir_is_dot_prux_under_home() {
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::HomeGuard::set(home.path());
assert_eq!(default_agent_dir(), home.path().join(PROJECT_SCOPE_NAME));
assert_eq!(default_agent_dir().file_name().unwrap(), ".prux");
}
#[test]
fn agent_dir_never_defaults_to_real_home_in_tests() {
let dir = agent_dir();
match std::env::var("PRUX_AGENT_DIR") {
Ok(pinned) if !pinned.is_empty() => {
assert_eq!(dir, expand_tilde(PathBuf::from(pinned)));
}
_ => {
assert_ne!(dir, default_agent_dir(), "测试构建不得落到真实 ~/.prux");
assert!(
dir.starts_with(std::env::temp_dir()),
"应回落到测试沙箱,实际 {}",
dir.display()
);
}
}
}
#[test]
fn extension_state_lists_merge_project_layer() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
let _ = write_settings_value("disabledExtensions", json!(["global-only"]));
let proj = std::env::temp_dir().join("prux-ext-proj");
let proj_conf = proj.join(".prux");
let _ = std::fs::remove_dir_all(&proj);
std::fs::create_dir_all(&proj_conf).unwrap();
std::fs::write(
proj_conf.join("settings.json"),
r#"{"disabledExtensions":["codemode","global-only"],"enabledExtensions":["plan-mode"]}"#,
)
.unwrap();
let cwd_untrusted = crate::test_support::CwdGuard::set(proj.clone());
let untrusted = read_disabled_extensions();
let untrusted_enabled = read_enabled_extensions();
drop(cwd_untrusted);
assert_eq!(untrusted, vec!["global-only".to_string()]);
assert!(untrusted_enabled.is_empty());
crate::core::project_trust::set_session_trust(&proj, true);
let cwd_trusted = crate::test_support::CwdGuard::set(proj.clone());
let merged = read_disabled_extensions();
let enabled = read_enabled_extensions();
drop(cwd_trusted);
assert_eq!(
merged,
vec!["global-only".to_string(), "codemode".to_string()]
);
assert_eq!(enabled, vec!["plan-mode".to_string()]);
crate::core::project_trust::clear_session_trust(&proj);
let _ = std::fs::remove_dir_all(&proj);
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn disabled_extensions_roundtrip_keeps_other_keys() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert!(read_disabled_extensions().is_empty());
write_disabled_extensions(&["a".to_string(), "b".to_string()]).unwrap();
let mut got = read_disabled_extensions();
got.sort();
assert_eq!(got, vec!["a".to_string(), "b".to_string()]);
let v = settings_value();
assert!(v.get("disabledExtensions").is_some());
write_disabled_extensions(&["c".to_string()]).unwrap();
assert_eq!(read_disabled_extensions(), vec!["c".to_string()]);
write_disabled_extensions(&[]).unwrap();
assert!(read_disabled_extensions().is_empty());
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn enabled_extensions_roundtrip_keeps_other_keys() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert!(read_enabled_extensions().is_empty());
write_theme("synthwave").unwrap();
write_extension_states(&["x".to_string()], &["plan-mode".to_string()]).unwrap();
assert_eq!(read_disabled_extensions(), vec!["x".to_string()]);
assert_eq!(read_enabled_extensions(), vec!["plan-mode".to_string()]);
assert_eq!(settings_value()["theme"], "synthwave");
write_extension_states(&[], &[]).unwrap();
assert!(read_disabled_extensions().is_empty());
assert!(read_enabled_extensions().is_empty());
assert_eq!(settings_value()["theme"], "synthwave");
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn default_model_and_provider_roundtrip() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
let s = read_settings();
assert!(s.default_provider.is_none());
assert!(s.default_model.is_none());
write_default_model_and_provider("deepseek", "deepseek-v4-pro").unwrap();
let s = read_settings();
assert_eq!(s.default_provider.as_deref(), Some("deepseek"));
assert_eq!(s.default_model.as_deref(), Some("deepseek-v4-pro"));
let v = settings_value();
assert!(v.get("theme").is_none());
write_default_model_and_provider("opencode", "some-model").unwrap();
let s = read_settings();
assert_eq!(s.default_provider.as_deref(), Some("opencode"));
assert_eq!(s.default_model.as_deref(), Some("some-model"));
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn enabled_models_roundtrip_and_collapse() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert!(read_enabled_models().is_empty());
assert!(read_settings().enabled_models.is_empty());
let pats = vec!["claude-*".to_string(), "deepseek-v4-pro:high".to_string()];
write_enabled_models(Some(&pats)).unwrap();
assert_eq!(read_enabled_models(), pats);
assert_eq!(read_settings().enabled_models, pats);
write_default_model_and_provider("deepseek", "deepseek-v4-pro").unwrap();
write_enabled_models(Some(&["gpt-4o".to_string()])).unwrap();
let s = read_settings();
assert_eq!(s.default_provider.as_deref(), Some("deepseek"));
assert_eq!(s.enabled_models, vec!["gpt-4o".to_string()]);
write_enabled_models(Some(&[])).unwrap();
assert!(read_enabled_models().is_empty());
assert!(settings_value().get("enabledModels").is_none());
write_enabled_models(Some(&["gpt-4o".to_string()])).unwrap();
write_enabled_models(None).unwrap();
assert!(settings_value().get("enabledModels").is_none());
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn default_tools_plus_minus_syntax() {
let s = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(
resolve_default_tools(&s(&["+web_search"])),
s(&["read", "bash", "edit", "write", "web_search"])
);
assert_eq!(
resolve_default_tools(&s(&["-bash"])),
s(&["read", "edit", "write"])
);
assert_eq!(
resolve_default_tools(&s(&["+bash"])),
s(&["read", "bash", "edit", "write"])
);
assert_eq!(
resolve_default_tools(&s(&["read", "-read", "+read"])),
s(&["read"])
);
assert_eq!(
resolve_default_tools(&s(&["read", "bash"])),
s(&["read", "bash"])
);
assert_eq!(resolve_default_tools(&s(&[])), Vec::<String>::new());
assert_eq!(
resolve_default_tools(&s(&["-nope"])),
s(&["read", "bash", "edit", "write"])
);
assert_eq!(
merge_default_tools(Some(&s(&["read"])), Some(&s(&["+bash"]))),
Some(s(&["read", "+bash"]))
);
assert_eq!(
merge_default_tools(Some(&s(&["read"])), Some(&s(&["edit", "+bash"]))),
Some(s(&["edit", "+bash"]))
);
assert_eq!(
merge_default_tools(Some(&s(&["read"])), None),
Some(s(&["read"]))
);
assert_eq!(merge_default_tools(None, None), None);
assert_eq!(
resolve_default_tools(&merge_default_tools(None, Some(&s(&["+web_search"]))).unwrap()),
s(&["read", "bash", "edit", "write", "web_search"])
);
}
#[test]
fn tool_list_validation_matches_pi() {
let s = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(get_tool_list_error(&s(&[])), None);
assert_eq!(get_tool_list_error(&s(&["read", "mcp__docs__*"])), None);
assert_eq!(get_tool_list_error(&s(&["+codemode", "-write"])), None);
assert_eq!(
get_tool_list_error(&s(&["read", "+bash"])),
Some("tool names cannot be mixed with +name or -name entries".to_string())
);
assert_eq!(
get_tool_list_error(&s(&["+mcp__docs__*"])),
Some(
"+name and -name entries take exact tool names, not patterns: +mcp__docs__*"
.to_string()
)
);
assert_eq!(
apply_tool_modifiers(&s(&["read"]), &s(&["+bash", "-read"])),
s(&["bash"])
);
assert_eq!(
apply_tool_modifiers(&s(&["read"]), &s(&["read", "+read*"])),
s(&["read", "read*"])
);
}
#[test]
fn default_tools_project_modifiers_layer_on_global() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
let _ = write_settings_value("defaultTools", json!(["read", "bash"]));
let proj = std::env::temp_dir().join("prux-dt-modifiers");
let proj_conf = proj.join(".prux");
let _ = std::fs::remove_dir_all(&proj);
std::fs::create_dir_all(&proj_conf).unwrap();
std::fs::write(
proj_conf.join("settings.json"),
r#"{"defaultTools":["+web_search","-bash"]}"#,
)
.unwrap();
crate::core::project_trust::set_session_trust(&proj, true);
let cwd = crate::test_support::CwdGuard::set(proj.clone());
let value = read_settings_default_tools();
drop(cwd);
assert_eq!(
value,
Some(vec!["read".to_string(), "web_search".to_string()])
);
crate::core::project_trust::clear_session_trust(&proj);
let _ = std::fs::remove_dir_all(&proj);
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn default_tools_global_and_project_override() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert_eq!(read_settings_default_tools(), None);
let _ = write_settings_value("defaultTools", json!(["read", "bash"]));
assert_eq!(
read_settings_default_tools(),
Some(vec!["read".to_string(), "bash".to_string()])
);
let proj = std::env::temp_dir().join("prux-dt-proj");
let proj_conf = proj.join(".prux");
let _ = std::fs::remove_dir_all(&proj);
std::fs::create_dir_all(&proj_conf).unwrap();
std::fs::write(
proj_conf.join("settings.json"),
r#"{"defaultTools":["edit","write"]}"#,
)
.unwrap();
let cwd_untrusted = crate::test_support::CwdGuard::set(proj.clone());
let untrusted = read_settings_default_tools();
drop(cwd_untrusted);
assert_eq!(
untrusted,
Some(vec!["read".to_string(), "bash".to_string()]),
"未受信任的项目 settings 必须被忽略"
);
crate::core::project_trust::set_session_trust(&proj, true);
let cwd_trusted = crate::test_support::CwdGuard::set(proj.clone());
let project_value = read_settings_default_tools();
drop(cwd_trusted);
assert_eq!(
project_value,
Some(vec!["edit".to_string(), "write".to_string()])
);
std::fs::write(proj_conf.join("settings.json"), r#"{"defaultTools":[]}"#).unwrap();
let cwd_empty = crate::test_support::CwdGuard::set(proj.clone());
let empty_value = read_settings_default_tools();
drop(cwd_empty);
assert_eq!(
empty_value,
Some(vec!["read".to_string(), "bash".to_string()]),
"项目空数组叠加在全局之上(不改变继承选择)"
);
let _ = std::fs::remove_dir_all(&proj);
crate::core::project_trust::clear_session_trust(&proj);
let _ = write_settings_value("defaultTools", json!([]));
assert_eq!(read_settings_default_tools(), Some(vec![]));
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn theme_roundtrip_keeps_other_keys() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert!(read_settings().theme.is_none());
write_theme("synthwave").unwrap();
let s = read_settings();
assert_eq!(s.theme.as_deref(), Some("synthwave"));
write_default_model_and_provider("deepseek", "deepseek-v4-pro").unwrap();
let s = read_settings();
assert_eq!(s.theme.as_deref(), Some("synthwave"));
assert_eq!(s.default_model.as_deref(), Some("deepseek-v4-pro"));
write_theme("dark").unwrap();
assert_eq!(read_settings().theme.as_deref(), Some("dark"));
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn session_share_destination_roundtrip_and_defaults() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert_eq!(
read_settings_session_share_destination(),
SessionShareDestination::Auto
);
std::fs::write(&path, r#"{"sessionShareDestination":"elsewhere"}"#).unwrap();
assert_eq!(
read_settings_session_share_destination(),
SessionShareDestination::Auto
);
write_settings_session_share_destination(SessionShareDestination::Radius).unwrap();
write_settings_follow_up_mode("all").unwrap();
assert_eq!(
read_settings_session_share_destination(),
SessionShareDestination::Radius
);
assert_eq!(read_settings_follow_up_mode(), "all");
write_settings_session_share_destination(SessionShareDestination::Gist).unwrap();
assert_eq!(
read_settings_session_share_destination(),
SessionShareDestination::Gist
);
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn compaction_model_overrides_take_precedence() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert_eq!(read_settings_compact_reserve(), DEFAULT_RESERVE_TOKENS);
assert_eq!(
read_settings_compact_keep_recent(),
DEFAULT_KEEP_RECENT_TOKENS
);
assert_eq!(
read_settings_compact_reserve_for("anthropic", "claude-opus-4-8"),
DEFAULT_RESERVE_TOKENS
);
write_settings_value(
"compaction",
json!({
"reserveTokens": 1000,
"keepRecentTokens": 2000,
"modelOverrides": {
"anthropic/claude-opus-4-8": {
"reserveTokens": 111,
"keepRecentTokens": 222
},
"openai/gpt-5.5": { "reserveTokens": 333 }
}
}),
)
.unwrap();
assert_eq!(read_settings_compact_reserve(), 1000);
assert_eq!(read_settings_compact_keep_recent(), 2000);
assert_eq!(
read_settings_compact_reserve_for("anthropic", "claude-opus-4-8"),
111
);
assert_eq!(
read_settings_compact_keep_recent_for("anthropic", "claude-opus-4-8"),
222
);
assert_eq!(read_settings_compact_reserve_for("openai", "gpt-5.5"), 333);
assert_eq!(
read_settings_compact_keep_recent_for("openai", "gpt-5.5"),
2000
);
assert_eq!(read_settings_compact_reserve_for("deepseek", "x"), 1000);
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn extension_mode_roundtrip_keeps_other_keys() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert_eq!(read_extension_mode(), "all");
write_extension_mode("minimal").unwrap();
assert_eq!(read_extension_mode(), "minimal");
write_disabled_extensions(&["x".to_string()]).unwrap();
assert_eq!(read_extension_mode(), "minimal");
write_extension_mode("all").unwrap();
assert_eq!(read_extension_mode(), "all");
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn new_selector_writers_roundtrip_and_keep_siblings() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert!(read_settings_images_auto_resize());
assert!(!read_settings_images_block_images());
assert!(read_settings_retry_enabled());
assert_eq!(read_settings_retry_max_retries(), 3);
assert_eq!(read_settings_temperature(), None);
assert_eq!(read_settings_compact_reserve(), DEFAULT_RESERVE_TOKENS);
assert_eq!(
read_settings_compact_keep_recent(),
DEFAULT_KEEP_RECENT_TOKENS
);
write_settings_images_auto_resize(false).unwrap();
write_settings_images_block_images(true).unwrap();
write_settings_retry_enabled(false).unwrap();
write_settings_retry_max_retries(10).unwrap();
write_settings_temperature(Some(0.2)).unwrap();
write_settings_compact_reserve(4096).unwrap();
write_settings_compact_keep_recent(8000).unwrap();
assert!(!read_settings_images_auto_resize());
assert!(read_settings_images_block_images(), "同组兄弟键不得互踩");
assert!(!read_settings_retry_enabled());
assert_eq!(read_settings_retry_max_retries(), 10, "同组兄弟键不得互踩");
assert_eq!(read_settings_temperature(), Some(0.2));
assert_eq!(read_settings_compact_reserve(), 4096);
assert_eq!(read_settings_compact_keep_recent(), 8000);
write_settings_temperature(None).unwrap();
assert_eq!(read_settings_temperature(), None);
assert!(
settings_value().get("temperature").is_none(),
"删键而不是写 null"
);
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn default_tool_toggle_writes_deltas_and_preserves_plain_lists() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
toggle_settings_default_tool("read", true).unwrap();
assert_eq!(settings_value()["defaultTools"], json!(["-read"]));
assert!(
!read_settings_default_tools()
.unwrap()
.contains(&"read".to_string())
);
toggle_settings_default_tool("grep", false).unwrap();
assert_eq!(settings_value()["defaultTools"], json!(["-read", "+grep"]));
assert!(
read_settings_default_tools()
.unwrap()
.contains(&"grep".to_string())
);
toggle_settings_default_tool("read", false).unwrap();
assert_eq!(settings_value()["defaultTools"], json!(["+grep"]));
toggle_settings_default_tool("grep", true).unwrap();
assert!(
settings_value().get("defaultTools").is_none(),
"条目清空后应删键"
);
std::fs::write(&path, r#"{"defaultTools":["read","bash"]}"#).unwrap();
toggle_settings_default_tool("grep", false).unwrap();
assert_eq!(
settings_value()["defaultTools"],
json!(["read", "bash", "+grep"])
);
toggle_settings_default_tool("read", true).unwrap();
assert_eq!(
settings_value()["defaultTools"],
json!(["read", "bash", "+grep", "-read"])
);
let effective = read_settings_default_tools().unwrap();
assert!(effective.contains(&"grep".to_string()));
assert!(!effective.contains(&"read".to_string()));
toggle_settings_default_tool("read", false).unwrap();
assert_eq!(
settings_value()["defaultTools"],
json!(["read", "bash", "+grep"])
);
std::fs::write(&path, r#"{"defaultTools":[]}"#).unwrap();
assert_eq!(read_settings_default_tools(), Some(Vec::new()));
toggle_settings_default_tool("read", false).unwrap();
assert_eq!(settings_value()["defaultTools"], json!(["read"]));
assert_eq!(
read_settings_default_tools(),
Some(vec!["read".to_string()])
);
toggle_settings_default_tool("read", true).unwrap();
assert_eq!(settings_value()["defaultTools"], json!(["read", "-read"]));
assert_eq!(read_settings_default_tools(), Some(Vec::new()));
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn settings_selector_roundtrips_keep_other_keys() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert!(read_settings_auto_compact());
assert_eq!(read_settings_steering_mode(), "one-at-a-time");
assert_eq!(read_settings_follow_up_mode(), "one-at-a-time");
assert!(!read_settings_hide_thinking());
assert_eq!(read_settings_autocomplete_max_visible(), 5);
assert_eq!(
read_settings_fullscreen_wheel_scroll_lines(),
WheelScrollLines::Auto,
"fullscreenWheelScrollLines 默认 auto"
);
assert!(read_settings_syntax_highlight(), "syntaxHighlight 默认启用");
assert!(read_settings_mermaid(), "mermaid 默认启用");
assert!(read_settings_latex(), "latex 默认启用");
write_settings_auto_compact(false).unwrap();
write_settings_steering_mode("all").unwrap();
write_settings_follow_up_mode("all").unwrap();
write_settings_hide_thinking(true).unwrap();
write_settings_autocomplete_max_visible(10).unwrap();
write_settings_fullscreen_wheel_scroll_lines(WheelScrollLines::Lines(3)).unwrap();
write_settings_syntax_highlight(false).unwrap();
write_settings_mermaid(false).unwrap();
write_settings_latex(false).unwrap();
write_settings_default_project_trust("always").unwrap();
write_settings_default_thinking_level("high").unwrap();
assert!(!read_settings_auto_compact());
assert_eq!(read_settings_steering_mode(), "all");
assert_eq!(read_settings_follow_up_mode(), "all");
assert!(read_settings_hide_thinking());
assert_eq!(read_settings_autocomplete_max_visible(), 10);
assert_eq!(
read_settings_fullscreen_wheel_scroll_lines(),
WheelScrollLines::Lines(3),
"固定行数写盘后读回"
);
write_settings_fullscreen_wheel_scroll_lines(WheelScrollLines::Lines(0)).unwrap();
assert_eq!(
read_settings_fullscreen_wheel_scroll_lines(),
WheelScrollLines::Lines(1)
);
write_settings_fullscreen_wheel_scroll_lines(WheelScrollLines::Auto).unwrap();
assert_eq!(
read_settings_fullscreen_wheel_scroll_lines(),
WheelScrollLines::Auto
);
assert!(!read_settings_syntax_highlight());
assert!(!read_settings_mermaid());
assert!(!read_settings_latex());
assert_eq!(
read_settings().default_project_trust.as_deref(),
Some("always")
);
assert_eq!(
read_settings().default_thinking_level.as_deref(),
Some("high")
);
write_settings_auto_compact(true).unwrap();
assert_eq!(read_settings_steering_mode(), "all");
assert_eq!(read_settings_autocomplete_max_visible(), 10);
assert!(!read_settings_syntax_highlight());
let v = settings_value();
assert_eq!(
v.get("defaultProjectTrust").and_then(|x| x.as_str()),
Some("always")
);
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn history_max_entries_distinguishes_zero_from_missing() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
assert_eq!(
read_settings_history_max_entries(),
HISTORY_MAX_ENTRIES_DEFAULT
);
write_settings_history_max_entries(0).unwrap();
assert_eq!(read_settings_history_max_entries(), 0);
for tier in HISTORY_MAX_ENTRIES_TIERS {
write_settings_history_max_entries(tier).unwrap();
assert_eq!(read_settings_history_max_entries(), tier);
}
write_settings_history_max_entries(42).unwrap();
assert_eq!(read_settings_history_max_entries(), 42);
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn settings_read_tolerates_bom() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
std::fs::write(&path, "\u{FEFF}{\"steeringMode\":\"one-at-a-time\"}").unwrap();
assert_eq!(settings_value()["steeringMode"], "one-at-a-time");
assert!(
settings_load_error().is_none(),
"带 BOM 的合法 settings 不应报解析错误"
);
write_settings_steering_mode("all").unwrap();
assert_eq!(settings_value()["steeringMode"], "all");
});
}
#[test]
fn settings_load_error_reports_invalid_json() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
std::fs::write(&path, "{invalid").unwrap();
let err = settings_load_error();
assert!(err.is_some(), "无效 settings 应报告警告");
assert!(err.unwrap().contains("Invalid settings file"));
});
}
#[test]
fn settings_cache_reflects_external_edits() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
std::fs::write(&path, r#"{"theme":"dark"}"#).unwrap();
assert_eq!(read_settings().theme.as_deref(), Some("dark"));
assert_eq!(read_extension_mode(), "all");
std::fs::write(&path, r#"{"theme":"nord","extensionMode":"minimal"}"#).unwrap();
assert_eq!(read_settings().theme.as_deref(), Some("nord"));
assert_eq!(read_extension_mode(), "minimal");
let _ = std::fs::remove_file(&path);
assert!(read_settings().theme.is_none());
assert_eq!(read_extension_mode(), "all");
});
}
#[test]
fn invalid_settings_is_never_overwritten() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let broken = r#"{"theme":"dark","autoCompact":false,"#;
std::fs::write(&path, broken).unwrap();
let err = write_theme("light").unwrap_err();
assert!(
err.to_string().contains("not valid JSON"),
"应报告文件无效: {}",
err
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
broken,
"无效 settings 不得被覆盖"
);
std::fs::write(&path, "[]").unwrap();
let err = write_settings_auto_compact(false).unwrap_err();
assert!(
err.to_string().contains("not a JSON object"),
"应报告非对象: {}",
err
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "[]");
std::fs::write(&path, r#"{"theme":"dark","newEntry":123}"#).unwrap();
write_theme("light").unwrap();
let v = settings_value();
assert_eq!(v["theme"], "light");
assert_eq!(v["newEntry"], 123, "已有条目应保留");
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn salvage_recovers_top_level_pairs_from_damaged_json() {
let m = salvage_settings_object(
r#"{"a":1,"b":{"c":2,"d":[1,{"e":"}"}]},"url":"http://x?a=1:b",,"g":true"#,
);
assert_eq!(m["a"], 1);
assert_eq!(m["b"]["c"], 2);
assert_eq!(m["b"]["d"][1]["e"], "}");
assert_eq!(m["url"], "http://x?a=1:b");
assert_eq!(m["g"], true);
assert_eq!(m.len(), 4, "不应把字符串内容误当成键: {m:?}");
let m = salvage_settings_object(r#"{"theme":"synthwave","extensionMode":"all","oops"#);
assert_eq!(m["theme"], "synthwave");
assert_eq!(m["extensionMode"], "all");
assert_eq!(m.len(), 2);
assert!(salvage_settings_object(r#"[{"a":1}]"#).is_empty());
assert!(salvage_settings_object("not json at all").is_empty());
assert!(salvage_settings_object("{invalid").is_empty());
}
#[test]
fn damaged_settings_without_snapshot_recovers_readable_keys() {
let _serial = crate::test_support::SETTINGS_OVERWRITE_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
std::fs::write(
&path,
r#"{"theme":"synthwave","extensionMode":"all","defaultProvider":"opencode-go","defaultModel":"mimo-v2.5",}"#,
)
.unwrap();
let s = read_settings();
assert_eq!(s.theme.as_deref(), Some("synthwave"));
assert_eq!(s.default_provider.as_deref(), Some("opencode-go"));
assert_eq!(s.default_model.as_deref(), Some("mimo-v2.5"));
assert_eq!(read_extension_mode(), "all");
let _ui = crate::test_support::SettingsUiGuard::set(true);
write_settings_auto_compact(false).unwrap();
confirm_settings_overwrite().unwrap();
let v = settings_value();
assert_eq!(v["autoCompact"], false);
assert_eq!(v["theme"], "synthwave");
assert_eq!(v["extensionMode"], "all");
assert_eq!(v["defaultProvider"], "opencode-go");
assert_eq!(v["defaultModel"], "mimo-v2.5");
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn pending_overwrite_is_dropped_once_file_is_writable() {
let _serial = crate::test_support::SETTINGS_OVERWRITE_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ui = crate::test_support::SettingsUiGuard::set(true);
std::fs::write(&path, r#"{"theme":"dark","#).unwrap();
write_theme("light").unwrap();
assert!(pending_settings_overwrite().is_some(), "损坏时应暂存");
std::fs::write(&path, r#"{"theme":"nord","newEntry":7}"#).unwrap();
write_settings_auto_compact(false).unwrap();
let v = settings_value();
assert_eq!(v["theme"], "nord", "手工修复的内容应优先于陈旧暂存");
assert_eq!(v["newEntry"], 7, "手工新增的键不得丢失");
assert_eq!(v["autoCompact"], false);
assert!(
pending_settings_overwrite().is_none(),
"文件已可写入,陈旧暂存应被丢弃"
);
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn writes_schema_reference_and_keeps_user_value() {
with_temp_agent_dir(|| {
write_settings_auto_compact(false).unwrap();
assert_eq!(settings_value()["$schema"], SETTINGS_SCHEMA_URL);
assert_eq!(settings_value()["autoCompact"], false);
let path = agent_dir().join("settings.json");
let custom = "https://example.com/my-settings.schema.json";
std::fs::write(&path, format!(r#"{{"$schema":"{custom}"}}"#)).unwrap();
write_settings_auto_compact(true).unwrap();
let v = settings_value();
assert_eq!(v["$schema"], custom, "用户改过的 $schema 不得被覆写");
assert_eq!(v["autoCompact"], true);
});
}
#[test]
fn concurrent_writes_do_not_lose_updates() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::test_support::pin_test_agent_dir();
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
let n: u64 = 16;
std::thread::scope(|s| {
for i in 0..n {
s.spawn(move || {
for _ in 0..20 {
write_settings_value(&format!("k{i}"), json!(i)).unwrap();
}
});
}
});
let v = settings_value();
let obj = v.as_object().expect("settings.json 应为对象");
for i in 0..n {
assert_eq!(
obj.get(&format!("k{i}")).and_then(|x| x.as_u64()),
Some(i),
"并发写入丢失键 k{i}: {obj:?}"
);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn readers_do_not_observe_truncated_settings_during_writes() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::test_support::pin_test_agent_dir();
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
write_disabled_extensions(&["footer(minimal)".to_string(), "footer(normal)".to_string()])
.unwrap();
let empty_reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
std::thread::scope(|s| {
{
let empty_reads = empty_reads.clone();
let stop = stop.clone();
s.spawn(move || {
while !stop.load(Ordering::Relaxed) {
if read_disabled_extensions().is_empty() {
empty_reads.fetch_add(1, Ordering::Relaxed);
}
}
});
}
for _ in 0..8 {
let done = done.clone();
let stop = stop.clone();
s.spawn(move || {
for _ in 0..300 {
write_theme("synthwave").unwrap();
}
if done.fetch_add(1, Ordering::Relaxed) + 1 == 8 {
stop.store(true, Ordering::Relaxed);
}
});
}
});
let empties = empty_reads.load(Ordering::Relaxed);
assert_eq!(
empties, 0,
"并发写期间读到空 disabledExtensions 次数={empties}"
);
assert_eq!(
read_disabled_extensions(),
vec!["footer(minimal)".to_string(), "footer(normal)".to_string()]
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn startup_read_write_concurrency_keeps_disabled_extensions() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::test_support::pin_test_agent_dir();
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
write_disabled_extensions(&["footer(minimal)".to_string(), "footer(normal)".to_string()])
.unwrap();
let start = std::sync::Barrier::new(2);
let empty_reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
std::thread::scope(|s| {
{
let start = &start;
let empty_reads = empty_reads.clone();
let stop = stop.clone();
s.spawn(move || {
start.wait();
while !stop.load(Ordering::Relaxed) {
if read_disabled_extensions().is_empty() {
empty_reads.fetch_add(1, Ordering::Relaxed);
}
}
});
}
{
let start = &start;
let stop = stop.clone();
s.spawn(move || {
start.wait();
for i in 0..500 {
write_default_model_and_provider("deepseek", &format!("m{i}")).unwrap();
}
stop.store(true, Ordering::Relaxed);
});
}
});
let empties = empty_reads.load(Ordering::Relaxed);
assert_eq!(
empties, 0,
"并发读期间读到空 disabledExtensions 次数={empties}"
);
assert_eq!(
read_disabled_extensions(),
vec!["footer(minimal)".to_string(), "footer(normal)".to_string()],
"并发读写后 disabledExtensions 不得丢失"
);
write_default_model_and_provider("opencode", "gpt-4o").unwrap();
assert_eq!(
read_disabled_extensions(),
vec!["footer(minimal)".to_string(), "footer(normal)".to_string()]
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn missing_settings_is_created_on_write() {
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let _ = std::fs::remove_file(&path);
write_theme("dark").unwrap();
assert_eq!(read_settings().theme.as_deref(), Some("dark"));
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn interactive_ui_stages_overwrite_and_can_confirm_or_cancel() {
let _serial = crate::test_support::SETTINGS_OVERWRITE_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
with_temp_agent_dir(|| {
let path = agent_dir().join("settings.json");
let broken = r#"{"theme":"dark","autoCompact":false,"#;
let _ui = crate::test_support::SettingsUiGuard::set(true);
std::fs::write(&path, broken).unwrap();
write_theme("light").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
let (shown_path, detail) = pending_settings_overwrite().expect("应暂存待确认覆写");
assert_eq!(shown_path, path.display().to_string());
assert!(
detail.contains("not valid JSON"),
"原因应含解析错误: {detail}"
);
write_settings_auto_compact(false).unwrap();
confirm_settings_overwrite().unwrap();
let v = settings_value();
assert_eq!(v["theme"], "light");
assert_eq!(v["autoCompact"], false);
assert!(pending_settings_overwrite().is_none());
std::fs::write(&path, broken).unwrap();
write_theme("dark").unwrap();
assert!(pending_settings_overwrite().is_some());
cancel_settings_overwrite();
assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
assert!(pending_settings_overwrite().is_none());
let _ = std::fs::remove_file(&path);
});
}
#[test]
fn show_images_defaults_to_false_and_round_trips() {
let _ad = crate::test_support::AgentDirGuard::temp();
assert!(!read_settings_show_images(), "未设置时默认关闭");
write_settings_show_images(true).unwrap();
assert!(read_settings_show_images());
assert_eq!(settings_value()["showImages"], json!(true));
write_settings_show_images(false).unwrap();
assert!(!read_settings_show_images());
assert_eq!(settings_value()["showImages"], json!(false));
}
#[test]
fn quiet_startup_reads_booleans_and_header_string() {
with_temp_agent_dir(|| {
let set = |value: Value| {
modify_global_settings(|obj| {
if value.is_null() {
obj.remove("quietStartup");
} else {
obj.insert("quietStartup".to_string(), value.clone());
}
})
.unwrap();
};
set(Value::Null);
assert_eq!(read_settings_quiet_startup(), QuietStartup::Header);
for (stored, expected) in [
(json!(false), QuietStartup::Full),
(json!(true), QuietStartup::Silent),
(json!("header"), QuietStartup::Header),
(json!("bogus"), QuietStartup::Header),
(json!(3), QuietStartup::Header),
] {
set(stored.clone());
assert_eq!(read_settings_quiet_startup(), expected, "stored: {stored}");
}
write_settings_quiet_startup(QuietStartup::Full).unwrap();
assert_eq!(settings_value()["quietStartup"], json!(false));
write_settings_quiet_startup(QuietStartup::Header).unwrap();
assert_eq!(settings_value()["quietStartup"], json!("header"));
write_settings_quiet_startup(QuietStartup::Silent).unwrap();
assert_eq!(settings_value()["quietStartup"], json!(true));
});
}
}