use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::config::{self, strip_line_comments, ConfigError};
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct RetrySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_agent_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<serde_json::Value>,
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct CompactionSettingsJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reserve_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keep_recent_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_overrides: Option<serde_json::Value>,
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct BranchSummarySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reserve_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_prompt: Option<bool>,
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum QueueModeSetting {
All,
OneAtATime,
}
impl From<QueueModeSetting> for rpi_agent::QueueMode {
fn from(mode: QueueModeSetting) -> Self {
match mode {
QueueModeSetting::All => rpi_agent::QueueMode::All,
QueueModeSetting::OneAtATime => rpi_agent::QueueMode::OneAtATime,
}
}
}
#[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>,
#[serde(default, alias = "enabledModels")]
pub scoped_models: Option<Vec<String>>,
#[serde(default)]
pub session_dir: Option<PathBuf>,
#[serde(default)]
pub shell_path: Option<String>,
#[serde(default)]
pub shell_command_prefix: Option<String>,
#[serde(default)]
pub http_proxy: Option<String>,
#[serde(default)]
pub retry: Option<RetrySettings>,
#[serde(default)]
pub compaction: Option<CompactionSettingsJson>,
#[serde(default)]
pub branch_summary: Option<BranchSummarySettings>,
#[serde(default)]
pub steering_mode: Option<QueueModeSetting>,
#[serde(default)]
pub follow_up_mode: Option<QueueModeSetting>,
#[serde(default, alias = "skills")]
pub skill_dirs: Option<Vec<String>>,
#[serde(default, alias = "prompts")]
pub prompt_dirs: Option<Vec<String>>,
#[serde(default, alias = "extensions")]
pub extension_dirs: Option<Vec<String>>,
#[serde(default)]
pub keybindings: Option<HashMap<String, serde_json::Value>>,
#[serde(default)]
pub double_escape_action: Option<String>,
#[serde(default)]
pub default_tools: Option<Vec<String>>,
#[serde(default)]
pub hide_thinking_block: Option<bool>,
#[serde(default)]
pub quiet_startup: Option<bool>,
#[serde(default)]
pub show_terminal_progress: Option<bool>,
#[serde(default)]
pub editor_padding_x: Option<usize>,
#[serde(default)]
pub autocomplete_max_visible: Option<usize>,
#[serde(default)]
pub fullscreen_copy_on_select: Option<bool>,
#[serde(default)]
pub output_pad: Option<usize>,
#[serde(default)]
pub show_cache_miss_notices: Option<bool>,
#[serde(default)]
pub show_images: Option<bool>,
#[serde(default)]
pub terminal: Option<TerminalSettings>,
#[serde(default, alias = "httpIdleTimeoutMs")]
pub http_idle_timeout: Option<serde_json::Value>,
}
#[derive(serde::Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct TerminalSettings {
#[serde(default)]
pub show_images: Option<bool>,
#[serde(default)]
pub show_terminal_progress: Option<bool>,
#[serde(default)]
pub clear_on_shrink: Option<bool>,
}
impl Settings {
pub fn http_idle_timeout_ms(&self) -> Option<i64> {
self.http_idle_timeout
.as_ref()
.and_then(rpi_ai::http::parse_http_idle_timeout_ms)
}
pub fn retry_policy(&self) -> rpi_harness::types::RetryPolicy {
let mut policy = rpi_harness::types::RetryPolicy::default();
let Some(retry) = &self.retry else {
return policy;
};
if let Some(enabled) = retry.enabled {
policy.enabled = enabled;
}
if let Some(max_retries) = retry.max_retries {
policy.max_retries = max_retries;
}
if let Some(base_delay_ms) = retry.base_delay_ms {
policy.base_delay_ms = base_delay_ms;
}
if let Some(max_agent_delay_ms) = retry.max_agent_delay_ms {
policy.max_agent_delay_ms = max_agent_delay_ms;
}
policy
}
pub fn compaction_settings(&self) -> rpi_harness::types::CompactionSettings {
let mut settings = rpi_harness::types::CompactionSettings::default();
let Some(compaction) = &self.compaction else {
return settings;
};
if let Some(enabled) = compaction.enabled {
settings.enabled = enabled;
}
if let Some(reserve_tokens) = compaction.reserve_tokens {
settings.reserve_tokens = reserve_tokens;
}
if let Some(keep_recent_tokens) = compaction.keep_recent_tokens {
settings.keep_recent_tokens = keep_recent_tokens;
}
settings
}
pub fn branch_summary_reserve_tokens(&self) -> Option<i64> {
self.branch_summary.as_ref().and_then(|s| s.reserve_tokens)
}
pub fn branch_summary_skipped(&self) -> bool {
self.branch_summary
.as_ref()
.and_then(|s| s.skip_prompt)
.unwrap_or(false)
}
pub fn steering_mode(&self) -> rpi_agent::QueueMode {
self.steering_mode
.map(Into::into)
.unwrap_or(rpi_agent::QueueMode::OneAtATime)
}
pub fn follow_up_mode(&self) -> rpi_agent::QueueMode {
self.follow_up_mode
.map(Into::into)
.unwrap_or(rpi_agent::QueueMode::OneAtATime)
}
pub fn show_images(&self) -> Option<bool> {
self.show_images
.or_else(|| self.terminal.as_ref().and_then(|t| t.show_images))
}
pub fn show_terminal_progress(&self) -> Option<bool> {
self.show_terminal_progress.or_else(|| {
self.terminal
.as_ref()
.and_then(|t| t.show_terminal_progress)
})
}
}
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 }),
}
}
pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
load_project_settings_with_paths(cwd)
.into_iter()
.map(|(_, settings)| settings)
.collect()
}
pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
load_active_project_settings(cwd)
.ok()
.flatten()
.into_iter()
.collect()
}
pub fn load_active_project_settings(
cwd: &Path,
) -> Result<Option<(PathBuf, Settings)>, ConfigError> {
let preferred = cwd.join(".rpi/settings.json");
match load_settings_file(&preferred) {
Ok(settings) => Ok(Some((preferred, settings))),
Err(ConfigError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
Ok(None)
}
Err(error) => Err(error),
}
}
pub fn load_effective_model_settings(
cwd: &Path,
project_trusted: bool,
) -> Result<Settings, ConfigError> {
let mut effective = load_settings()?;
if project_trusted {
if let Some((_, project)) = load_active_project_settings(cwd)? {
if project.default_provider.is_some() {
effective.default_provider = project.default_provider;
}
if project.default_model.is_some() {
effective.default_model = project.default_model;
}
if project.default_thinking_level.is_some() {
effective.default_thinking_level = project.default_thinking_level;
}
if project.theme.is_some() {
effective.theme = project.theme;
}
}
}
Ok(effective)
}
pub fn load_project_settings_for_write(cwd: &Path) -> Result<Settings, ConfigError> {
Ok(load_active_project_settings(cwd)?
.map(|(_, settings)| settings)
.unwrap_or_default())
}
fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
parse_settings(&text).map_err(|source| ConfigError::Json {
path: path.to_path_buf(),
source,
})
}
pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(|value| {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
base.join(path)
}
})
.collect()
}
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)
}
}
}
fn parse_settings_value(text: &str) -> Result<serde_json::Value, serde_json::Error> {
match serde_json::from_str::<serde_json::Value>(text) {
Ok(value) => Ok(value),
Err(first) => {
let stripped = strip_line_comments(text);
serde_json::from_str::<serde_json::Value>(&stripped).map_err(|_| first)
}
}
}
pub fn save_settings(settings: &Settings) -> Result<(), String> {
let path = config::settings_path().map_err(|e| e.to_string())?;
save_settings_to_path(settings, &path, None)
}
pub fn save_project_settings(cwd: &Path, settings: &Settings) -> Result<(), String> {
save_settings_to_path(settings, &cwd.join(".rpi/settings.json"), None)
}
fn save_settings_to_path(
settings: &Settings,
path: &Path,
fallback: Option<&Path>,
) -> Result<(), String> {
let mut merged = match std::fs::read_to_string(path) {
Ok(text) => parse_settings_value(&text).map_err(|error| {
format!(
"cannot parse existing settings file {}: {error}",
path.display()
)
})?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
read_fallback_settings_value(path, fallback)?
.unwrap_or_else(|| serde_json::Value::Object(Default::default()))
}
Err(error) => {
return Err(format!(
"cannot read existing settings file {}: {error}",
path.display()
));
}
};
let obj = merged
.as_object_mut()
.ok_or_else(|| format!("settings file {} is not an object", path.display()))?;
for (key, val) in [
("defaultProvider", settings.default_provider.as_ref()),
("defaultModel", settings.default_model.as_ref()),
(
"defaultThinkingLevel",
settings.default_thinking_level.as_ref(),
),
("theme", settings.theme.as_ref()),
] {
match val {
Some(v) => {
obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
}
None => {
obj.remove(key);
}
}
}
match &settings.scoped_models {
Some(list) if !list.is_empty() => {
obj.insert(
"scopedModels".to_string(),
serde_json::Value::Array(
list.iter()
.map(|m| serde_json::Value::String(m.clone()))
.collect(),
),
);
}
_ => {
obj.remove("scopedModels");
}
}
match &settings.keybindings {
Some(bindings) => {
obj.insert(
"keybindings".to_string(),
serde_json::to_value(bindings).map_err(|e| e.to_string())?,
);
}
None => {
obj.remove("keybindings");
}
}
match settings.double_escape_action.as_deref() {
Some(action) if !action.trim().is_empty() => {
obj.insert(
"doubleEscapeAction".to_string(),
serde_json::Value::String(action.to_string()),
);
}
_ => {
obj.remove("doubleEscapeAction");
}
}
for (key, value) in [
(
"hideThinkingBlock",
settings.hide_thinking_block.map(serde_json::Value::Bool),
),
(
"quietStartup",
settings.quiet_startup.map(serde_json::Value::Bool),
),
(
"showTerminalProgress",
settings.show_terminal_progress.map(serde_json::Value::Bool),
),
(
"showImages",
settings.show_images.map(serde_json::Value::Bool),
),
(
"showCacheMissNotices",
settings
.show_cache_miss_notices
.map(serde_json::Value::Bool),
),
(
"fullscreenCopyOnSelect",
settings
.fullscreen_copy_on_select
.map(serde_json::Value::Bool),
),
(
"editorPaddingX",
settings
.editor_padding_x
.map(|v| serde_json::Value::Number(v.into())),
),
(
"outputPad",
settings
.output_pad
.map(|v| serde_json::Value::Number(v.into())),
),
(
"autocompleteMaxVisible",
settings
.autocomplete_max_visible
.map(|v| serde_json::Value::Number(v.into())),
),
("httpIdleTimeout", settings.http_idle_timeout.clone()),
] {
match value {
Some(value) => {
obj.insert(key.to_string(), value);
}
None => {
obj.remove(key);
}
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
config::atomic_write(path, text.as_bytes()).map_err(|e| e.to_string())
}
fn read_fallback_settings_value(
target: &Path,
fallback: Option<&Path>,
) -> Result<Option<serde_json::Value>, String> {
let Some(path) = fallback else {
return Ok(None);
};
if path == target {
return Ok(None);
}
match std::fs::read_to_string(&path) {
Ok(text) => parse_settings_value(&text).map(Some).map_err(|error| {
format!(
"cannot parse fallback settings file {}: {error}",
path.display()
)
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!(
"cannot read fallback settings file {}: {error}",
path.display()
)),
}
}
#[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());
assert!(s.skill_dirs.is_none());
assert!(s.prompt_dirs.is_none());
assert!(s.extension_dirs.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,
"quietStartup": true,
"showTerminalProgress": false,
"editorPaddingX": 3,
"autocompleteMaxVisible": 7,
"compaction": { "threshold": 100 }
}"#,
)
.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"));
assert_eq!(s.hide_thinking_block, Some(true));
assert_eq!(s.quiet_startup, Some(true));
assert_eq!(s.show_terminal_progress, Some(false));
assert_eq!(s.editor_padding_x, Some(3));
assert_eq!(s.autocomplete_max_visible, Some(7));
}
#[test]
fn project_settings_only_use_rpi() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
std::fs::write(
tmp.path().join(".rpi/settings.json"),
r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
)
.unwrap();
let settings = load_project_settings(tmp.path());
assert_eq!(settings.len(), 1);
assert_eq!(
settings[0].skill_dirs.as_deref(),
Some(["rpi-skills".to_string()].as_slice())
);
assert_eq!(
settings[0].extension_dirs.as_deref(),
Some(["rpi-ext".to_string()].as_slice())
);
}
#[test]
fn malformed_rpi_project_settings_are_not_read() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
std::fs::write(tmp.path().join(".rpi/settings.json"), "{ malformed").unwrap();
assert!(load_project_settings_with_paths(tmp.path()).is_empty());
assert!(load_active_project_settings(tmp.path()).is_err());
}
#[test]
fn trusted_project_model_defaults_override_global_defaults() {
let _cfg = TempConfig::new();
let global = config::settings_path().unwrap();
std::fs::create_dir_all(global.parent().unwrap()).unwrap();
std::fs::write(
global,
r#"{"defaultProvider":"global","defaultModel":"global-model","theme":"dark"}"#,
)
.unwrap();
let project = tempfile::tempdir().unwrap();
std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
std::fs::write(
project.path().join(".rpi/settings.json"),
r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
)
.unwrap();
let trusted = load_effective_model_settings(project.path(), true).unwrap();
assert_eq!(trusted.default_provider.as_deref(), Some("project"));
assert_eq!(trusted.default_model.as_deref(), Some("project-model"));
assert_eq!(trusted.theme.as_deref(), Some("dark"));
let untrusted = load_effective_model_settings(project.path(), false).unwrap();
assert_eq!(untrusted.default_provider.as_deref(), Some("global"));
assert_eq!(untrusted.default_model.as_deref(), Some("global-model"));
}
#[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 { .. })));
}
}
#[cfg(test)]
mod scoped_tests {
use super::*;
use crate::config::test_support::env_lock;
fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
let guard = env_lock().lock().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
(tmp, guard)
}
#[test]
fn session_dir_setting_selects_the_session_directory() {
let settings: Settings =
serde_json::from_str(r#"{"sessionDir": "/tmp/from-settings"}"#).unwrap();
assert_eq!(
settings.session_dir,
Some(std::path::PathBuf::from("/tmp/from-settings"))
);
assert_eq!(Settings::default().session_dir, None);
}
#[test]
fn shell_command_prefix_reaches_the_bash_tool() {
let settings: Settings =
serde_json::from_str(r#"{"shellCommandPrefix": "shopt -s expand_aliases"}"#).unwrap();
assert_eq!(
settings.shell_command_prefix.as_deref(),
Some("shopt -s expand_aliases")
);
let with_shell: Settings =
serde_json::from_str(r#"{"shellPath": "/usr/bin/zsh"}"#).unwrap();
assert_eq!(with_shell.shell_path.as_deref(), Some("/usr/bin/zsh"));
}
#[test]
fn http_proxy_setting_is_read() {
let settings: Settings =
serde_json::from_str(r#"{"httpProxy": "http://127.0.0.1:8888"}"#).unwrap();
assert_eq!(
settings.http_proxy.as_deref(),
Some("http://127.0.0.1:8888")
);
assert_eq!(Settings::default().http_proxy, None);
}
#[test]
fn harness_options_come_from_settings() {
let settings: Settings = serde_json::from_str(
r#"{
"retry": { "enabled": false, "maxRetries": 7, "baseDelayMs": 250, "maxAgentDelayMs": 9000 },
"compaction": { "enabled": false, "reserveTokens": 1234, "keepRecentTokens": 4321 },
"steeringMode": "all",
"followUpMode": "all"
}"#,
)
.expect("settings parse");
let retry = settings.retry_policy();
assert!(!retry.enabled);
assert_eq!(retry.max_retries, 7);
assert_eq!(retry.base_delay_ms, 250);
assert_eq!(retry.max_agent_delay_ms, 9000);
let compaction = settings.compaction_settings();
assert!(!compaction.enabled);
assert_eq!(compaction.reserve_tokens, 1234);
assert_eq!(compaction.keep_recent_tokens, 4321);
assert_eq!(settings.steering_mode(), rpi_agent::QueueMode::All);
assert_eq!(settings.follow_up_mode(), rpi_agent::QueueMode::All);
}
#[test]
fn absent_harness_settings_keep_the_harness_defaults() {
let empty = Settings::default();
let default_retry = rpi_harness::types::RetryPolicy::default();
let retry = empty.retry_policy();
assert_eq!(retry.enabled, default_retry.enabled);
assert_eq!(retry.max_retries, default_retry.max_retries);
assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
assert_eq!(retry.max_agent_delay_ms, default_retry.max_agent_delay_ms);
let default_compaction = rpi_harness::types::CompactionSettings::default();
let compaction = empty.compaction_settings();
assert_eq!(compaction.enabled, default_compaction.enabled);
assert_eq!(compaction.reserve_tokens, default_compaction.reserve_tokens);
assert_eq!(
compaction.keep_recent_tokens,
default_compaction.keep_recent_tokens
);
assert_eq!(empty.steering_mode(), rpi_agent::QueueMode::OneAtATime);
assert_eq!(empty.follow_up_mode(), rpi_agent::QueueMode::OneAtATime);
let partial: Settings = serde_json::from_str(r#"{"retry":{"maxRetries":9}}"#).unwrap();
let retry = partial.retry_policy();
assert_eq!(retry.max_retries, 9);
assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
}
#[test]
fn native_settings_key_names_are_accepted() {
let settings: Settings = serde_json::from_str(
r#"{
"enabledModels": ["a", "b"],
"httpIdleTimeoutMs": 1500,
"branchSummary": { "reserveTokens": 4242, "skipPrompt": true }
}"#,
)
.expect("native key names parse");
assert_eq!(
settings.scoped_models,
Some(vec!["a".to_string(), "b".to_string()]),
"native's `enabledModels` must map onto rpi's scoped models"
);
assert_eq!(
settings.http_idle_timeout_ms(),
Some(1500),
"native's `httpIdleTimeoutMs` must be read"
);
assert_eq!(settings.branch_summary_reserve_tokens(), Some(4242));
assert!(settings.branch_summary_skipped());
}
#[test]
fn save_load_scoped_models_roundtrip() {
let (_tmp, _guard) = with_temp_env();
let mut s = Settings::default();
s.scoped_models = Some(vec!["a".into(), "b".into()]);
save_settings(&s).unwrap();
let loaded = load_settings().unwrap();
assert_eq!(
loaded.scoped_models,
Some(vec!["a".to_string(), "b".to_string()])
);
let mut s2 = load_settings().unwrap();
s2.scoped_models = None;
save_settings(&s2).unwrap();
assert_eq!(load_settings().unwrap().scoped_models, None);
}
#[test]
fn save_uses_atomic_sibling_replacement() {
let (_tmp, _guard) = with_temp_env();
let path = config::settings_path().unwrap();
std::fs::write(&path, r#"{"theme":"dark","piOnlyField":true}"#).unwrap();
let mut settings = load_settings().unwrap();
settings.theme = Some("light".into());
save_settings(&settings).unwrap();
let saved: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(saved["theme"], "light");
assert_eq!(saved["piOnlyField"], true);
let temp = path.with_file_name(format!(
".{}.tmp",
path.file_name().and_then(|name| name.to_str()).unwrap()
));
assert!(!temp.exists(), "atomic staging file should not remain");
}
#[test]
fn save_preserves_unknown_fields_and_writes_the_tui_settings() {
let (_tmp, _guard) = with_temp_env();
let path = config::settings_path().unwrap();
let mut settings = Settings::default();
settings.show_images = Some(false);
settings.show_cache_miss_notices = Some(true);
settings.output_pad = Some(0);
settings.fullscreen_copy_on_select = Some(false);
settings.http_idle_timeout = Some(serde_json::json!(30_000));
settings.show_terminal_progress = Some(false);
save_settings(&settings).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(raw["showImages"], false);
assert_eq!(raw["showCacheMissNotices"], true);
assert_eq!(raw["outputPad"], 0);
assert_eq!(raw["fullscreenCopyOnSelect"], false);
assert_eq!(raw["httpIdleTimeout"], 30_000);
assert_eq!(raw["showTerminalProgress"], false);
let reloaded = load_settings().unwrap();
assert_eq!(reloaded.show_images(), Some(false));
assert_eq!(reloaded.show_cache_miss_notices, Some(true));
assert_eq!(reloaded.output_pad, Some(0));
assert_eq!(reloaded.fullscreen_copy_on_select, Some(false));
assert_eq!(reloaded.http_idle_timeout_ms(), Some(30_000));
assert_eq!(reloaded.show_terminal_progress(), Some(false));
}
#[test]
fn nested_terminal_block_is_accepted_as_a_fallback() {
let (_tmp, _guard) = with_temp_env();
let path = config::settings_path().unwrap();
std::fs::write(
&path,
r#"{ "terminal": { "showImages": false, "showTerminalProgress": false } }"#,
)
.unwrap();
let settings = load_settings().unwrap();
assert_eq!(settings.show_images(), Some(false));
assert_eq!(settings.show_terminal_progress(), Some(false));
std::fs::write(
&path,
r#"{
"showImages": true,
"terminal": { "showImages": false }
}"#,
)
.unwrap();
let settings = load_settings().unwrap();
assert_eq!(settings.show_images(), Some(true));
}
#[test]
fn save_preserves_unknown_fields() {
let (_tmp, _guard) = with_temp_env();
let path = config::settings_path().unwrap();
std::fs::write(
&path,
r#"{
"piOnlyField": "keep-me",
"theme": "dark"
}"#,
)
.unwrap();
let mut s = load_settings().unwrap();
s.scoped_models = Some(vec!["m1".into()]);
save_settings(&s).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(raw["piOnlyField"], "keep-me");
assert_eq!(raw["scopedModels"][0], "m1");
assert_eq!(raw["theme"], "dark");
}
#[test]
fn save_preserves_unknown_fields_and_packages_with_line_comments() {
let (_tmp, _guard) = with_temp_env();
let path = config::settings_path().unwrap();
let original = r#"{
// upstream permits comments in settings files.
"piOnlyField": { "keep": true },
"theme": "dark"
}
"#;
std::fs::write(&path, original).unwrap();
let mut settings = load_settings().unwrap();
settings.scoped_models = Some(vec!["m1".into()]);
save_settings(&settings).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(raw["piOnlyField"]["keep"], true);
assert_eq!(raw["theme"], "dark");
assert_eq!(raw["scopedModels"][0], "m1");
}
#[test]
fn save_fails_closed_for_unparseable_existing_settings() {
let (_tmp, _guard) = with_temp_env();
let path = config::settings_path().unwrap();
let original = b"{\n // keep this file intact\n \"piOnlyField\": \"keep-me\"\n";
std::fs::write(&path, original).unwrap();
let mut settings = Settings::default();
settings.theme = Some("light".into());
let error =
save_settings(&settings).expect_err("malformed settings must not be overwritten");
assert!(error.contains("cannot parse existing settings file"));
assert_eq!(std::fs::read(&path).unwrap(), original);
}
}