use crate::{
config::custom_provider_config::validate_custom_provider_settings,
persistence::{CrossProcessFileLock, atomic_write, in_process_file_lock},
subagents::{DEFAULT_SUBAGENT_MAX_DEPTH, MAX_SUBAGENT_MAX_DEPTH},
thinking::ThinkingLevel,
};
use anyhow::Context;
use regex::Regex;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de};
use std::{
collections::{BTreeMap, BTreeSet},
fs, io,
net::IpAddr,
path::{Path, PathBuf},
time::Duration,
};
use super::{
CustomProviderConfig, HookSettings, McPaths,
custom_provider_config::validate_custom_provider_id,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsScope {
Global,
Project,
}
impl SettingsScope {
pub fn label(self) -> &'static str {
match self {
Self::Global => "Global",
Self::Project => "Project",
}
}
pub fn toggle(self) -> Self {
match self {
Self::Global => Self::Project,
Self::Project => Self::Global,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsListKind {
Skills,
Tools,
Subagents,
Models,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TextVerbosity {
#[default]
Low,
Medium,
High,
}
impl TextVerbosity {
pub fn as_api_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct OpenAiResponsesSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_verbosity: Option<TextVerbosity>,
}
impl OpenAiResponsesSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct OpenAiCodexSettings {
#[serde(default)]
pub text_verbosity: TextVerbosity,
}
impl OpenAiCodexSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub enum AnthropicCacheTtl {
#[default]
#[serde(rename = "5m")]
FiveMinutes,
#[serde(rename = "1h")]
OneHour,
}
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
pub struct Settings {
#[serde(default, skip_serializing_if = "SelectedModelSettings::is_default")]
pub selected_model: SelectedModelSettings,
#[serde(default, skip_serializing_if = "OpenAiCodexSettings::is_default")]
pub openai_codex: OpenAiCodexSettings,
#[serde(default, skip_serializing_if = "OpenAiResponsesSettings::is_default")]
pub openai_responses: OpenAiResponsesSettings,
pub no_color: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anthropic_cache_ttl: Option<AnthropicCacheTtl>,
#[serde(
default = "default_file_autocomplete_respects_gitignore",
skip_serializing_if = "is_true"
)]
pub file_autocomplete_respects_gitignore: bool,
pub context: Option<crate::context::ContextBudget>,
#[serde(default, skip_serializing_if = "SessionTitleSettings::is_default")]
pub session_titles: SessionTitleSettings,
#[serde(default, skip_serializing_if = "CompactionSettings::is_default")]
pub compaction: CompactionSettings,
#[serde(default, skip_serializing_if = "ToolSettings::is_default")]
pub tools: ToolSettings,
#[serde(default, skip_serializing_if = "SubagentsSettings::is_default")]
pub subagents: SubagentsSettings,
#[serde(default, skip_serializing_if = "ModelsSettings::is_default")]
pub models: ModelsSettings,
#[serde(default, skip_serializing_if = "HookSettings::is_default")]
pub hooks: HookSettings,
#[serde(default, skip_serializing_if = "InstructionsSettings::is_default")]
pub instructions: InstructionsSettings,
#[serde(default, skip_serializing_if = "SkillsSettings::is_default")]
pub skills: SkillsSettings,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub custom_providers: BTreeMap<String, CustomProviderConfig>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub mcp_servers: McpServersSettings,
#[serde(default, skip_serializing_if = "LspSettings::is_default")]
pub lsp: LspSettings,
#[serde(default, skip_serializing_if = "IntegrationsSettings::is_default")]
pub integrations: IntegrationsSettings,
#[serde(default, skip_serializing_if = "TuiSettings::is_default")]
pub tui: TuiSettings,
#[serde(default, skip_serializing_if = "ProviderStreamSettings::is_default")]
pub provider_stream: ProviderStreamSettings,
#[serde(default, skip_serializing_if = "TtsrSettings::is_default")]
pub ttsr: TtsrSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_primary_agent: Option<String>,
}
impl Default for Settings {
fn default() -> Self {
Self {
openai_responses: OpenAiResponsesSettings::default(),
selected_model: SelectedModelSettings::default(),
openai_codex: OpenAiCodexSettings::default(),
no_color: None,
anthropic_cache_ttl: None,
file_autocomplete_respects_gitignore: true,
context: None,
session_titles: SessionTitleSettings::default(),
compaction: CompactionSettings::default(),
tools: ToolSettings::default(),
subagents: SubagentsSettings::default(),
models: ModelsSettings::default(),
hooks: HookSettings::default(),
instructions: InstructionsSettings::default(),
skills: SkillsSettings::default(),
custom_providers: BTreeMap::new(),
mcp_servers: BTreeMap::new(),
lsp: LspSettings::default(),
integrations: IntegrationsSettings::default(),
tui: TuiSettings::default(),
provider_stream: ProviderStreamSettings::default(),
ttsr: TtsrSettings::default(),
selected_primary_agent: None,
}
}
}
impl Settings {
pub(crate) fn text_verbosity_for(&self, provider: &str) -> Option<TextVerbosity> {
if provider == crate::providers::OPENAI_CODEX_PROVIDER {
return Some(
self.openai_responses
.text_verbosity
.unwrap_or(self.openai_codex.text_verbosity),
);
}
self.custom_providers.get(provider).and_then(|custom| {
(custom.use_responses_endpoint && custom.supports_text_verbosity)
.then_some(self.openai_responses.text_verbosity)
.flatten()
})
}
}
fn default_file_autocomplete_respects_gitignore() -> bool {
true
}
fn is_true(value: &bool) -> bool {
*value
}
fn is_false(value: &bool) -> bool {
!*value
}
pub type McpServersSettings = BTreeMap<String, McpServerConfig>;
pub type LspServersSettings = BTreeMap<String, LspServerConfig>;
pub const DEFAULT_LSP_DIAGNOSTICS_WAIT_MS: u64 = 2_000;
pub const DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES: u64 = 10;
pub const MAX_LSP_DIAGNOSTICS_WAIT_MS: u64 = 30_000;
pub const MAX_LSP_IDLE_SHUTDOWN_MINUTES: u64 = 240;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct LspSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_true")]
pub inject_diagnostics_on_edit: bool,
#[serde(default = "default_lsp_diagnostics_wait_ms")]
pub diagnostics_wait_ms: u64,
#[serde(default = "default_lsp_idle_shutdown_minutes")]
pub idle_shutdown_minutes: u64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub servers: LspServersSettings,
}
impl Default for LspSettings {
fn default() -> Self {
Self {
enabled: false,
inject_diagnostics_on_edit: true,
diagnostics_wait_ms: default_lsp_diagnostics_wait_ms(),
idle_shutdown_minutes: default_lsp_idle_shutdown_minutes(),
servers: BTreeMap::new(),
}
}
}
impl LspSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct LspServerConfig {
pub command: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
pub fn default_lsp_diagnostics_wait_ms() -> u64 {
DEFAULT_LSP_DIAGNOSTICS_WAIT_MS
}
pub fn default_lsp_idle_shutdown_minutes() -> u64 {
DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES
}
pub const DEFAULT_MCP_TIMEOUT_SECONDS: u64 = 30;
pub const MAX_MCP_TIMEOUT_SECONDS: u64 = 300;
#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum McpServerConfig {
Stdio(McpStdioServerConfig),
Http(McpHttpServerConfig),
}
impl std::fmt::Debug for McpServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stdio(config) => f.debug_tuple("Stdio").field(config).finish(),
Self::Http(config) => f.debug_tuple("Http").field(config).finish(),
}
}
}
impl McpServerConfig {
pub(crate) fn enabled(&self) -> bool {
match self {
Self::Stdio(config) => config.enabled,
Self::Http(config) => config.enabled,
}
}
pub(crate) fn set_enabled(&mut self, enabled: bool) {
match self {
Self::Stdio(config) => config.enabled = enabled,
Self::Http(config) => config.enabled = enabled,
}
}
}
#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct McpStdioServerConfig {
pub command: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
}
impl std::fmt::Debug for McpStdioServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpStdioServerConfig")
.field("command", &self.command)
.field("args", &self.args)
.field("env", &format_args!("<{} vars redacted>", self.env.len()))
.field("enabled", &self.enabled)
.field("timeout", &self.timeout)
.finish()
}
}
#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct McpHttpServerConfig {
pub url: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<McpOAuthConfig>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
}
#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
pub struct McpOAuthConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_server: Option<String>,
}
impl std::fmt::Debug for McpOAuthConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpOAuthConfig")
.field("client_id", &self.client_id.as_ref().map(|_| "[REDACTED]"))
.field("scopes", &self.scopes)
.field(
"authorization_server",
&self
.authorization_server
.as_ref()
.map(|url| sanitize_mcp_http_url_for_display(url)),
)
.finish()
}
}
impl std::fmt::Debug for McpHttpServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpHttpServerConfig")
.field("url", &sanitize_mcp_http_url_for_display(&self.url))
.field(
"headers",
&crate::mcp::headers::redact_headers(&self.headers),
)
.field("oauth", &self.oauth)
.field("enabled", &self.enabled)
.field("timeout", &self.timeout)
.finish()
}
}
pub(crate) fn validate_settings(settings: &Settings) -> anyhow::Result<()> {
validate_custom_provider_settings(settings)?;
validate_context_model_overrides(settings.context.as_ref())?;
validate_mcp_servers_settings(&settings.mcp_servers)?;
validate_lsp_settings(&settings.lsp)?;
settings.provider_stream.validate()?;
settings.subagents.validate()?;
settings.ttsr.validate()?;
Ok(())
}
fn validate_context_model_overrides(
context: Option<&crate::context::ContextBudget>,
) -> anyhow::Result<()> {
let Some(context) = context else {
return Ok(());
};
for (key, model_override) in &context.model_overrides {
let Some((provider, model)) = key.split_once('/') else {
anyhow::bail!("context.model_overrides key '{key}' must use provider/model");
};
if provider.is_empty() || model.is_empty() {
anyhow::bail!("context.model_overrides key '{key}' must use non-empty provider/model");
}
if key
.chars()
.any(|ch| ch.is_ascii_whitespace() || ch.is_ascii_control())
{
anyhow::bail!(
"context.model_overrides key '{key}' must not contain ASCII whitespace or control characters"
);
}
if model_override.is_empty() {
anyhow::bail!(
"context.model_overrides.{key} must set at least one of max_tokens, reserve_tokens, or keep_recent_tokens"
);
}
}
Ok(())
}
pub(crate) fn validate_lsp_settings(settings: &LspSettings) -> anyhow::Result<()> {
if !(1..=MAX_LSP_DIAGNOSTICS_WAIT_MS).contains(&settings.diagnostics_wait_ms) {
anyhow::bail!(
"lsp.diagnostics_wait_ms must be between 1 and {MAX_LSP_DIAGNOSTICS_WAIT_MS} milliseconds"
);
}
if !(1..=MAX_LSP_IDLE_SHUTDOWN_MINUTES).contains(&settings.idle_shutdown_minutes) {
anyhow::bail!(
"lsp.idle_shutdown_minutes must be between 1 and {MAX_LSP_IDLE_SHUTDOWN_MINUTES} minutes"
);
}
for (name, config) in &settings.servers {
validate_lsp_server_name(name)?;
if config.command.trim().is_empty() {
anyhow::bail!("lsp.servers.{name}.command must not be empty");
}
}
Ok(())
}
fn validate_lsp_server_name(name: &str) -> anyhow::Result<()> {
if name.trim().is_empty() {
anyhow::bail!("lsp server name must not be empty");
}
if name.contains("__") {
anyhow::bail!("lsp server name '{name}' must not contain '__'");
}
Ok(())
}
pub(crate) fn validate_mcp_servers_settings(servers: &McpServersSettings) -> anyhow::Result<()> {
for (name, config) in servers {
validate_mcp_server_name(name)?;
match config {
McpServerConfig::Stdio(stdio) => {
if stdio.command.trim().is_empty() {
anyhow::bail!("mcp_servers.{name}.command must not be empty");
}
validate_mcp_timeout(name, stdio.timeout)?;
}
McpServerConfig::Http(http) => validate_mcp_http_server(name, http)?,
}
}
Ok(())
}
fn validate_mcp_timeout(name: &str, timeout: Option<u64>) -> anyhow::Result<()> {
if let Some(timeout) = timeout
&& !(1..=MAX_MCP_TIMEOUT_SECONDS).contains(&timeout)
{
anyhow::bail!(
"mcp_servers.{name}.timeout must be between 1 and {MAX_MCP_TIMEOUT_SECONDS} seconds"
);
}
Ok(())
}
fn sanitize_mcp_http_url_for_display(url: &str) -> String {
match reqwest::Url::parse(url) {
Ok(parsed) => {
let host = parsed.host_str().unwrap_or("<unknown>");
let port = parsed
.port()
.map(|port| format!(":{port}"))
.unwrap_or_default();
format!("{}://{}{}{}", parsed.scheme(), host, port, parsed.path())
}
Err(_) => "<invalid-url>".to_string(),
}
}
fn validate_mcp_http_server(name: &str, config: &McpHttpServerConfig) -> anyhow::Result<()> {
validate_mcp_http_url(name, &config.url)?;
validate_mcp_timeout(name, config.timeout)?;
if let Some(oauth) = &config.oauth {
validate_mcp_oauth_config(name, oauth, &config.headers)?;
}
for (header_name, header_value) in &config.headers {
validate_mcp_http_header_name(name, header_name)?;
let env_ref = crate::mcp::headers::parse_env_header_ref(header_value);
if crate::mcp::headers::is_env_header_ref_syntax(header_value) && env_ref.is_none() {
anyhow::bail!(
"mcp_servers.{name}.headers.{header_name} must use {{env:VAR_NAME}} with a valid environment variable name"
);
}
if crate::mcp::headers::is_sensitive_header(header_name) && env_ref.is_none() {
anyhow::bail!(
"mcp_servers.{name}.headers.{header_name} is sensitive and must use {{env:VAR_NAME}}"
);
}
}
Ok(())
}
fn validate_mcp_oauth_config(
name: &str,
oauth: &McpOAuthConfig,
headers: &BTreeMap<String, String>,
) -> anyhow::Result<()> {
for header_name in headers.keys() {
if header_name.eq_ignore_ascii_case("authorization")
|| header_name.eq_ignore_ascii_case("proxy-authorization")
{
anyhow::bail!(
"mcp_servers.{name}.headers.{header_name} must not be configured when mcp_servers.{name}.oauth is configured"
);
}
}
if let Some(client_id) = &oauth.client_id
&& client_id.trim().is_empty()
{
anyhow::bail!("mcp_servers.{name}.oauth.client_id must not be empty");
}
for scope in &oauth.scopes {
if scope.trim().is_empty()
|| scope
.bytes()
.any(|byte| !byte.is_ascii() || byte.is_ascii_control())
{
anyhow::bail!(
"mcp_servers.{name}.oauth.scopes entries must be non-empty printable ASCII"
);
}
}
if let Some(url) = &oauth.authorization_server {
validate_mcp_http_url_field(name, "oauth.authorization_server", url)?;
}
Ok(())
}
fn validate_mcp_http_url(name: &str, url: &str) -> anyhow::Result<()> {
validate_mcp_http_url_field(name, "url", url)
}
pub(crate) fn validate_mcp_http_url_field(
name: &str,
field: &str,
url: &str,
) -> anyhow::Result<()> {
let parsed = reqwest::Url::parse(url)
.map_err(|_| anyhow::anyhow!("mcp_servers.{name}.{field} must be an absolute HTTP URL"))?;
if !parsed.username().is_empty() || parsed.password().is_some() {
anyhow::bail!("mcp_servers.{name}.{field} must not contain credentials");
}
match parsed.scheme() {
"https" => Ok(()),
"http" if is_loopback_http_host(parsed.host_str()) => Ok(()),
"http" => anyhow::bail!(
"mcp_servers.{name}.{field} must use https; http is allowed only for loopback hosts"
),
_ => anyhow::bail!("mcp_servers.{name}.{field} must use http or https"),
}
}
fn is_loopback_http_host(host: Option<&str>) -> bool {
match host {
Some("localhost") => true,
Some(host) => host
.trim_matches(['[', ']'])
.parse::<IpAddr>()
.is_ok_and(|ip| ip.is_loopback()),
None => false,
}
}
fn validate_mcp_http_header_name(server_name: &str, header_name: &str) -> anyhow::Result<()> {
if header_name.is_empty() {
anyhow::bail!("mcp_servers.{server_name}.headers contains an empty header name");
}
if header_name
.bytes()
.any(|byte| !byte.is_ascii() || byte.is_ascii_control() || byte == b':' || byte == b' ')
{
anyhow::bail!(
"mcp_servers.{server_name}.headers.{header_name} must be visible ASCII without colon, spaces, or control characters"
);
}
Ok(())
}
pub(crate) fn validate_mcp_server_name(name: &str) -> anyhow::Result<()> {
if name.is_empty() {
anyhow::bail!("mcp server name must not be empty");
}
if name.contains("__") {
anyhow::bail!("mcp server name '{name}' must not contain '__'");
}
if !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
{
anyhow::bail!(
"mcp server name '{name}' must contain only ASCII letters, digits, '_' or '-'"
);
}
Ok(())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ProviderStreamSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic_progress_timeout_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_semantic_progress_timeout_seconds: Option<u64>,
}
pub const DEFAULT_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS: u64 = 60;
pub const DEFAULT_SUBAGENT_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS: u64 = 120;
pub const MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS: u64 = 600;
impl ProviderStreamSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn semantic_progress_timeout(&self) -> Duration {
Duration::from_secs(
self.semantic_progress_timeout_seconds
.unwrap_or(DEFAULT_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS),
)
}
pub(crate) fn subagent_semantic_progress_timeout(&self) -> Duration {
Duration::from_secs(
self.subagent_semantic_progress_timeout_seconds
.unwrap_or(DEFAULT_SUBAGENT_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS),
)
}
fn validate(&self) -> anyhow::Result<()> {
validate_provider_stream_timeout(
"provider_stream.semantic_progress_timeout_seconds",
self.semantic_progress_timeout_seconds,
)?;
validate_provider_stream_timeout(
"provider_stream.subagent_semantic_progress_timeout_seconds",
self.subagent_semantic_progress_timeout_seconds,
)
}
}
fn validate_provider_stream_timeout(field: &str, value: Option<u64>) -> anyhow::Result<()> {
if let Some(value) = value
&& !(1..=MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS).contains(&value)
{
anyhow::bail!(
"{field} must be between 1 and {MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS} seconds"
);
}
Ok(())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct TtsrSettings {
#[serde(default, skip_serializing_if = "is_false")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<TtsrRuleSetting>,
}
impl TtsrSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
fn validate(&self) -> anyhow::Result<()> {
if self.rules.len() > 128 {
anyhow::bail!("ttsr.rules must contain at most 128 rules");
}
for (index, rule) in self.rules.iter().enumerate() {
if rule.pattern.trim().is_empty() {
anyhow::bail!("ttsr.rules[{index}].pattern must not be empty");
}
Regex::new(&rule.pattern).map_err(|_| {
anyhow::anyhow!("ttsr.rules[{index}].pattern must be a valid regex")
})?;
if rule.reminder.trim().is_empty() {
anyhow::bail!("ttsr.rules[{index}].reminder must not be empty");
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct TtsrRuleSetting {
pub pattern: String,
pub reminder: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SelectedModelSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_level: Option<ThinkingLevel>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl SelectedModelSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ToolOutputCompressionSettings {
#[serde(default)]
pub enabled: bool,
}
impl ToolOutputCompressionSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ToolSettings {
#[serde(default, skip_serializing_if = "ReadToolSettings::is_default")]
pub read: ReadToolSettings,
#[serde(default, skip_serializing_if = "ViewImageToolSettings::is_default")]
pub view_image: ViewImageToolSettings,
#[serde(default, skip_serializing_if = "HashEditToolSettings::is_default")]
pub hash_edit: HashEditToolSettings,
#[serde(default, skip_serializing_if = "WriteToolSettings::is_default")]
pub write: WriteToolSettings,
#[serde(
default,
alias = "ffgrep",
skip_serializing_if = "GrepToolSettings::is_default"
)]
pub grep: GrepToolSettings,
#[serde(
default,
alias = "fffind",
skip_serializing_if = "FindToolSettings::is_default"
)]
pub find: FindToolSettings,
#[serde(default, skip_serializing_if = "ListFilesToolSettings::is_default")]
pub list_files: ListFilesToolSettings,
#[serde(default, skip_serializing_if = "RepoMapToolSettings::is_default")]
pub repo_map: RepoMapToolSettings,
#[serde(default, skip_serializing_if = "AstGrepToolSettings::is_default")]
pub ast_grep: AstGrepToolSettings,
#[serde(default, skip_serializing_if = "BashToolSettings::is_default")]
pub bash: BashToolSettings,
#[serde(
default,
alias = "parallel_subagents",
skip_serializing_if = "SubagentsToolSettings::is_default"
)]
pub subagents: SubagentsToolSettings,
#[serde(
default,
skip_serializing_if = "ToolOutputCompressionSettings::is_default"
)]
pub output_compression: ToolOutputCompressionSettings,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
}
impl ToolSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
pub const DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 2;
pub const MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 5;
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SubagentsSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_validation_max_retries: Option<u64>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl SubagentsSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn schema_validation_max_retries(&self) -> u64 {
self.schema_validation_max_retries
.unwrap_or(DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES)
}
fn validate(&self) -> anyhow::Result<()> {
if let Some(retries) = self.schema_validation_max_retries
&& retries > MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES
{
anyhow::bail!(
"subagents.schema_validation_max_retries must be between 0 and {MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES}"
);
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ModelsSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl ModelsSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
macro_rules! absolute_path_tool_settings {
($($name:ident),+ $(,)?) => {
$(
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct $name {
#[serde(default = "default_true")]
pub absolute_paths: bool,
}
impl Default for $name {
fn default() -> Self {
Self {
absolute_paths: true,
}
}
}
impl $name {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
)+
};
}
absolute_path_tool_settings!(
ReadToolSettings,
HashEditToolSettings,
WriteToolSettings,
GrepToolSettings,
FindToolSettings,
ListFilesToolSettings,
RepoMapToolSettings,
AstGrepToolSettings,
);
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SubagentsToolSettings {
#[serde(default = "default_true")]
pub absolute_paths: bool,
#[serde(default = "default_subagent_max_depth")]
pub max_depth: usize,
}
impl Default for SubagentsToolSettings {
fn default() -> Self {
Self {
absolute_paths: true,
max_depth: DEFAULT_SUBAGENT_MAX_DEPTH,
}
}
}
impl SubagentsToolSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
fn default_subagent_max_depth() -> usize {
DEFAULT_SUBAGENT_MAX_DEPTH
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct BashToolSettings {
#[serde(default = "default_true")]
pub absolute_paths: bool,
#[serde(default = "default_true")]
pub shell_expansion: bool,
}
impl Default for BashToolSettings {
fn default() -> Self {
Self {
absolute_paths: true,
shell_expansion: true,
}
}
}
impl BashToolSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
fn default_true() -> bool {
true
}
pub const DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
pub const MAX_VIEW_IMAGE_MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ViewImageToolSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vision_model: Option<ViewImageVisionModelSettings>,
#[serde(default = "default_true")]
pub absolute_paths: bool,
#[serde(default = "default_view_image_max_image_bytes")]
pub max_image_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ViewImageVisionModelSettings {
pub provider: String,
pub model: String,
}
impl ViewImageToolSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
impl Default for ViewImageToolSettings {
fn default() -> Self {
Self {
vision_model: None,
absolute_paths: true,
max_image_bytes: DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES,
}
}
}
pub fn default_view_image_max_image_bytes() -> u64 {
DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES
}
pub fn validate_view_image_identifier(field: &str, value: &str) -> anyhow::Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
anyhow::bail!("tools.view_image.vision_model.{field} must not be empty");
}
if trimmed
.chars()
.any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
{
anyhow::bail!(
"tools.view_image.vision_model.{field} must not contain ASCII whitespace or control characters"
);
}
if crate::config::looks_like_secret_value(trimmed) {
anyhow::bail!("tools.view_image.vision_model.{field} must not look like a secret value");
}
Ok(trimmed.to_string())
}
pub fn validate_view_image_max_image_bytes(value: u64) -> anyhow::Result<u64> {
if !(1..=MAX_VIEW_IMAGE_MAX_IMAGE_BYTES).contains(&value) {
anyhow::bail!(
"tools.view_image.max_image_bytes must be between 1 and {MAX_VIEW_IMAGE_MAX_IMAGE_BYTES}"
);
}
Ok(value)
}
pub(crate) fn clamp_subagent_max_depth(max_depth: usize) -> usize {
max_depth.clamp(1, MAX_SUBAGENT_MAX_DEPTH)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct IntegrationsSettings {
#[serde(default, skip_serializing_if = "HerdrSettings::is_default")]
pub herdr: HerdrSettings,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl IntegrationsSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Default, Deserialize)]
struct SettingsWire {
#[serde(default)]
selected_model: SelectedModelSettings,
#[serde(default)]
openai_codex: OpenAiCodexSettings,
#[serde(default)]
openai_responses: OpenAiResponsesSettings,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
no_color: Option<bool>,
#[serde(default)]
anthropic_cache_ttl: Option<AnthropicCacheTtl>,
#[serde(default = "default_file_autocomplete_respects_gitignore")]
file_autocomplete_respects_gitignore: bool,
#[serde(default)]
context: Option<crate::context::ContextBudget>,
#[serde(default)]
session_titles: SessionTitleSettings,
#[serde(default)]
compaction: CompactionSettings,
#[serde(default)]
tools: ToolSettings,
#[serde(default)]
subagents: SubagentsSettings,
#[serde(default)]
models: ModelsSettings,
#[serde(default)]
hooks: HookSettings,
#[serde(default)]
instructions: InstructionsSettings,
#[serde(default)]
skills: SkillsSettings,
#[serde(default)]
custom_providers: BTreeMap<String, CustomProviderConfig>,
#[serde(default)]
mcp_servers: McpServersSettings,
#[serde(default)]
lsp: LspSettings,
#[serde(default)]
integrations: IntegrationsSettings,
#[serde(default)]
herdr: HerdrSettings,
#[serde(default)]
tui: TuiSettings,
#[serde(default)]
provider_stream: ProviderStreamSettings,
#[serde(default)]
ttsr: TtsrSettings,
#[serde(default)]
thinking_level: Option<ThinkingLevel>,
#[serde(default)]
selected_primary_agent: Option<String>,
#[serde(default)]
disabled_skills: Vec<String>,
}
impl<'de> Deserialize<'de> for Settings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let mut wire: SettingsWire =
serde_json::from_value(value.clone()).map_err(de::Error::custom)?;
let root = value.as_object();
if !has_path(root, &["selected_model", "provider"]) {
wire.selected_model.provider = wire.provider;
}
if !has_path(root, &["selected_model", "model"]) {
wire.selected_model.model = wire.model;
}
if !has_path(root, &["selected_model", "thinking_level"]) {
wire.selected_model.thinking_level = wire.thinking_level;
}
if !has_path(root, &["integrations", "herdr"]) && wire.integrations.herdr.is_default() {
wire.integrations.herdr = wire.herdr;
}
if !has_path(root, &["skills", "disabled"]) && wire.skills.disabled.is_empty() {
wire.skills.disabled = wire.disabled_skills;
}
let settings = Self {
selected_model: wire.selected_model,
openai_codex: wire.openai_codex,
openai_responses: wire.openai_responses,
no_color: wire.no_color,
anthropic_cache_ttl: wire.anthropic_cache_ttl,
file_autocomplete_respects_gitignore: wire.file_autocomplete_respects_gitignore,
context: wire.context,
session_titles: wire.session_titles,
compaction: wire.compaction,
tools: wire.tools,
subagents: wire.subagents,
models: wire.models,
hooks: wire.hooks,
instructions: wire.instructions,
skills: wire.skills,
custom_providers: wire.custom_providers,
mcp_servers: wire.mcp_servers,
lsp: wire.lsp,
integrations: wire.integrations,
tui: wire.tui,
provider_stream: wire.provider_stream,
ttsr: wire.ttsr,
selected_primary_agent: wire.selected_primary_agent,
};
Ok(settings)
}
}
fn has_path(root: Option<&serde_json::Map<String, serde_json::Value>>, path: &[&str]) -> bool {
let Some(mut current) = root.and_then(|object| object.get(path[0])) else {
return false;
};
for key in &path[1..] {
let Some(next) = current.as_object().and_then(|object| object.get(*key)) else {
return false;
};
current = next;
}
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct HerdrSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl HerdrSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct TuiSettings {
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl TuiSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct InstructionsSettings {
#[serde(default, skip_serializing_if = "is_false")]
pub subdir_discovery: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_markdown_paths: Vec<PathBuf>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl InstructionsSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SkillsSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_paths: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, serde_json::Value>,
}
impl SkillsSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct CompactionSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CompactionConfig {
pub(crate) provider: String,
pub(crate) model: String,
}
impl CompactionSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn resolve_config(
&self,
active_provider: &str,
active_model: &str,
) -> Result<CompactionConfig, String> {
let provider = self.provider.as_deref().map(str::trim);
let model = self.model.as_deref().map(str::trim);
match (provider, model) {
(None, None) => Ok(CompactionConfig {
provider: non_blank_active(active_provider, "provider")?.to_string(),
model: non_blank_active(active_model, "model")?.to_string(),
}),
(Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
Ok(CompactionConfig {
provider: provider.to_string(),
model: model.to_string(),
})
}
_ => Err("compaction.provider and compaction.model must either both be configured and non-blank, or both be omitted to inherit the active provider/model".to_string()),
}
}
}
fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
Err(format!(
"active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
))
} else {
Ok(trimmed)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SessionTitleSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionTitleConfig {
pub(crate) provider: String,
pub(crate) model: String,
}
impl SessionTitleSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
if !self.enabled {
return Ok(None);
}
let provider = self
.provider
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "session_titles.enabled is true but session_titles.provider is missing or blank; set an explicit title provider or disable session_titles".to_string())?;
let model = self
.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "session_titles.enabled is true but session_titles.model is missing or blank; set an explicit title model or disable session_titles".to_string())?;
Ok(Some(SessionTitleConfig {
provider: provider.to_string(),
model: model.to_string(),
}))
}
}
pub(crate) fn upsert_custom_provider(
paths: &McPaths,
id: &str,
mut config: CustomProviderConfig,
) -> anyhow::Result<()> {
let id = validate_custom_provider_id(id)?;
update_settings_preserving_unknown_top_level_fields(paths, |settings| {
if let Some(existing) = settings.custom_providers.get(&id) {
config.models_dev_provider = existing.models_dev_provider.clone();
config.use_responses_endpoint = existing.use_responses_endpoint;
config.reasoning_protocol = existing.reasoning_protocol;
config.extra_models = existing.extra_models.clone();
}
settings.custom_providers.insert(id, config);
})
}
pub(crate) fn remove_custom_provider(paths: &McPaths, id: &str) -> anyhow::Result<bool> {
let id = validate_custom_provider_id(id)?;
let mut removed = false;
update_settings_preserving_unknown_top_level_fields(paths, |settings| {
removed = settings.custom_providers.remove(&id).is_some();
})?;
Ok(removed)
}
pub(crate) const SETTINGS_SCHEMA_RELATIVE_REF: &str = "./state/settings.schema.json";
const SETTINGS_SCHEMA_FILE_NAME: &str = "settings.schema.json";
pub(crate) fn ensure_settings_schema_files(paths: &McPaths) -> anyhow::Result<()> {
let schema_path = paths.state.join(SETTINGS_SCHEMA_FILE_NAME);
let schema = schemars::schema_for!(Settings);
write_file_if_changed(
&schema_path,
serde_json::to_string_pretty(&schema)?.as_bytes(),
)?;
let settings_lock = settings_file_lock(&paths.settings_file)?;
let _settings_guard = settings_lock
.lock()
.map_err(|_| anyhow::anyhow!("settings lock was poisoned"))?;
let _file_guard = CrossProcessFileLock::acquire(&paths.settings_file)?;
let original = match fs::read_to_string(&paths.settings_file) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let mut raw = serde_json::json!({});
insert_default_schema_ref_if_absent(&mut raw);
atomic_write(
&paths.settings_file,
serde_json::to_string_pretty(&raw)?.as_bytes(),
)?;
return Ok(());
}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to read {}", paths.settings_file.display()));
}
};
let mut raw: serde_json::Value = match serde_json::from_str(&original) {
Ok(value) => value,
Err(_) => return Ok(()),
};
if !raw.is_object() {
return Ok(());
}
let settings: Settings = match serde_json::from_value(raw.clone()) {
Ok(settings) => settings,
Err(_) => return Ok(()),
};
if validate_settings(&settings).is_err() {
return Ok(());
}
if insert_default_schema_ref_if_absent(&mut raw) {
atomic_write(
&paths.settings_file,
serde_json::to_string_pretty(&raw)?.as_bytes(),
)?;
}
Ok(())
}
fn write_file_if_changed(path: &Path, bytes: &[u8]) -> anyhow::Result<bool> {
match fs::read(path) {
Ok(existing) if existing == bytes => Ok(false),
Ok(_) => {
atomic_write(path, bytes)?;
Ok(true)
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
atomic_write(path, bytes)?;
Ok(true)
}
Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
}
}
fn insert_default_schema_ref_if_absent(raw: &mut serde_json::Value) -> bool {
let Some(object) = raw.as_object_mut() else {
return false;
};
if object.contains_key("$schema") {
return false;
}
object.insert(
"$schema".to_string(),
serde_json::Value::String(SETTINGS_SCHEMA_RELATIVE_REF.to_string()),
);
true
}
pub(crate) fn disabled_skill_names_from_settings(settings: &Settings) -> BTreeSet<String> {
normalized_name_set(&settings.skills.disabled)
}
pub(crate) fn disabled_tool_names_from_settings(settings: &Settings) -> BTreeSet<String> {
normalized_name_set(&settings.tools.disabled)
}
pub(crate) fn disabled_subagent_profile_names_from_settings(
settings: &Settings,
) -> BTreeSet<String> {
normalized_name_set(&settings.subagents.disabled)
}
pub(crate) fn disabled_model_ids_from_settings(settings: &Settings) -> BTreeSet<String> {
normalized_name_set(&settings.models.disabled)
}
fn normalized_name_set(names: &[String]) -> BTreeSet<String> {
names
.iter()
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.map(ToString::to_string)
.collect()
}
fn disabled_names_from_settings(settings: &Settings, kind: SettingsListKind) -> BTreeSet<String> {
match kind {
SettingsListKind::Skills => disabled_skill_names_from_settings(settings),
SettingsListKind::Tools => disabled_tool_names_from_settings(settings),
SettingsListKind::Subagents => disabled_subagent_profile_names_from_settings(settings),
SettingsListKind::Models => disabled_model_ids_from_settings(settings),
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn disabled_skill_names(paths: &McPaths) -> anyhow::Result<BTreeSet<String>> {
Ok(disabled_skill_names_from_settings(&read_settings(paths)?))
}
pub(crate) fn selected_primary_agent(paths: &McPaths) -> anyhow::Result<Option<String>> {
Ok(read_settings(paths)?.selected_primary_agent)
}
pub(crate) fn load_config_with_settings(
paths: McPaths,
cli: super::CliConfigOverrides,
) -> anyhow::Result<(super::EffectiveConfig, Settings)> {
let settings = read_settings(&paths)?;
let config = super::EffectiveConfig::from_loaded_settings(paths, cli, settings.clone())?;
Ok((config, settings))
}
pub(crate) fn set_thinking_level(
paths: &McPaths,
level: ThinkingLevel,
) -> anyhow::Result<ThinkingLevel> {
update_settings_preserving_unknown_top_level_fields(paths, |settings| {
settings.selected_model.thinking_level = Some(level);
})?;
Ok(level)
}
pub(crate) fn set_selected_primary_agent(
paths: &McPaths,
selected: Option<&str>,
) -> anyhow::Result<Option<String>> {
let selected = selected
.map(crate::primary_agents::validate_primary_agent_id)
.transpose()?;
update_settings_preserving_unknown_top_level_fields(paths, |settings| {
settings.selected_primary_agent = selected.clone();
})?;
Ok(selected)
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn set_skill_disabled(
paths: &McPaths,
skill_name: &str,
disabled: bool,
) -> anyhow::Result<BTreeSet<String>> {
set_skill_disabled_for_scope(paths, SettingsScope::Global, skill_name, disabled)
}
pub(crate) fn set_skill_disabled_for_scope(
paths: &McPaths,
scope: SettingsScope,
skill_name: &str,
disabled: bool,
) -> anyhow::Result<BTreeSet<String>> {
update_disabled_name_for_scope(paths, scope, SettingsListKind::Skills, skill_name, disabled)
}
pub(crate) fn set_tool_disabled(
paths: &McPaths,
scope: SettingsScope,
tool_name: &str,
disabled: bool,
) -> anyhow::Result<BTreeSet<String>> {
update_disabled_name_for_scope(paths, scope, SettingsListKind::Tools, tool_name, disabled)
}
pub(crate) fn set_subagent_profile_disabled(
paths: &McPaths,
scope: SettingsScope,
profile_id: &str,
disabled: bool,
) -> anyhow::Result<BTreeSet<String>> {
update_disabled_name_for_scope(
paths,
scope,
SettingsListKind::Subagents,
profile_id,
disabled,
)
}
pub(crate) fn set_model_disabled_for_scope(
paths: &McPaths,
scope: SettingsScope,
model_id: &str,
disabled: bool,
) -> anyhow::Result<BTreeSet<String>> {
update_disabled_name_for_scope(paths, scope, SettingsListKind::Models, model_id, disabled)
}
fn update_disabled_name_for_scope(
paths: &McPaths,
scope: SettingsScope,
kind: SettingsListKind,
name: &str,
disabled: bool,
) -> anyhow::Result<BTreeSet<String>> {
let name = name.trim();
if name.is_empty() {
anyhow::bail!("setting name must be non-empty");
}
let seed_from_effective =
scope == SettingsScope::Project && !scope_has_disabled_list(paths, scope, kind)?;
let mut effective = if seed_from_effective {
disabled_names_from_settings(&read_settings(paths)?, kind)
} else {
disabled_names_from_settings(&read_settings_for_scope(paths, scope)?, kind)
};
if disabled {
effective.insert(name.to_string());
} else {
effective.remove(name);
}
let list = effective.iter().cloned().collect::<Vec<_>>();
update_settings_for_scope_preserving_unknown_top_level_fields(paths, scope, |settings| {
match kind {
SettingsListKind::Skills => settings.skills.disabled = list.clone(),
SettingsListKind::Tools => settings.tools.disabled = list.clone(),
SettingsListKind::Subagents => settings.subagents.disabled = list.clone(),
SettingsListKind::Models => settings.models.disabled = list.clone(),
}
})?;
if scope == SettingsScope::Project && list.is_empty() {
preserve_explicit_empty_disabled_list(paths, scope, kind)?;
}
Ok(effective)
}
pub(crate) fn disabled_names_for_modal_scope(
paths: &McPaths,
scope: SettingsScope,
kind: SettingsListKind,
) -> anyhow::Result<BTreeSet<String>> {
if scope == SettingsScope::Project && !scope_has_disabled_list(paths, scope, kind)? {
return Ok(disabled_names_from_settings(&read_settings(paths)?, kind));
}
Ok(disabled_names_from_settings(
&read_settings_for_scope(paths, scope)?,
kind,
))
}
pub(crate) fn scope_has_disabled_list(
paths: &McPaths,
scope: SettingsScope,
kind: SettingsListKind,
) -> anyhow::Result<bool> {
let raw = read_settings_json_or_empty(&settings_path_for_scope(paths, scope))?;
Ok(match kind {
SettingsListKind::Skills => has_path(raw.as_object(), &["skills", "disabled"]),
SettingsListKind::Tools => has_path(raw.as_object(), &["tools", "disabled"]),
SettingsListKind::Subagents => has_path(raw.as_object(), &["subagents", "disabled"]),
SettingsListKind::Models => has_path(raw.as_object(), &["models", "disabled"]),
})
}
pub(crate) fn set_mcp_server_enabled(
paths: &McPaths,
name: &str,
enabled: bool,
) -> anyhow::Result<()> {
validate_mcp_server_name(name)?;
let name = name.to_string();
let mut found = false;
update_settings_preserving_unknown_top_level_fields(paths, |settings| {
if let Some(config) = settings.mcp_servers.get_mut(&name) {
config.set_enabled(enabled);
found = true;
}
})?;
if !found {
anyhow::bail!("mcp server not found: {name}");
}
Ok(())
}
pub(crate) fn set_selected_model(
paths: &McPaths,
provider: &str,
model: &str,
) -> anyhow::Result<()> {
update_settings_preserving_unknown_top_level_fields(paths, |settings| {
settings.selected_model.provider = Some(provider.to_string());
settings.selected_model.model = Some(model.to_string());
})
}
pub(crate) fn update_settings_preserving_unknown_top_level_fields(
paths: &McPaths,
mutate: impl FnOnce(&mut Settings),
) -> anyhow::Result<()> {
update_settings_for_scope_preserving_unknown_top_level_fields(
paths,
SettingsScope::Global,
mutate,
)
}
pub(crate) fn read_settings_for_scope(
paths: &McPaths,
scope: SettingsScope,
) -> anyhow::Result<Settings> {
let raw = read_settings_json_or_empty(&settings_path_for_scope(paths, scope))?;
let settings: Settings = serde_json::from_value(raw)?;
validate_settings(&settings)?;
Ok(settings)
}
pub(crate) fn update_settings_for_scope_preserving_unknown_top_level_fields(
paths: &McPaths,
scope: SettingsScope,
mutate: impl FnOnce(&mut Settings),
) -> anyhow::Result<()> {
prepare_settings_scope_dir(paths, scope)?;
let target = settings_path_for_scope(paths, scope);
let settings_lock = settings_file_lock(&target)?;
let _settings_guard = settings_lock
.lock()
.map_err(|_| anyhow::anyhow!("settings lock was poisoned"))?;
let _file_guard = CrossProcessFileLock::acquire(&target)?;
let original = read_settings_text_or_empty(&target)?;
let mut raw: serde_json::Value = serde_json::from_str(&original)?;
if !raw.is_object() {
raw = serde_json::json!({});
}
let mut settings: Settings = serde_json::from_value(raw.clone())?;
mutate(&mut settings);
validate_settings(&settings)?;
update_raw_from_settings(&mut raw, &settings, scope)?;
atomic_write(&target, serde_json::to_string_pretty(&raw)?.as_bytes())?;
Ok(())
}
fn settings_path_for_scope(paths: &McPaths, scope: SettingsScope) -> PathBuf {
match scope {
SettingsScope::Global => paths.settings_file.clone(),
SettingsScope::Project => paths.project_settings_file.clone(),
}
}
fn prepare_settings_scope_dir(paths: &McPaths, scope: SettingsScope) -> anyhow::Result<()> {
match scope {
SettingsScope::Global => fs::create_dir_all(&paths.root)?,
SettingsScope::Project => {
validate_project_settings_target(paths)?;
if let Some(parent) = paths.project_settings_file.parent() {
fs::create_dir_all(parent)?;
}
}
}
Ok(())
}
fn validate_project_settings_target(paths: &McPaths) -> anyhow::Result<()> {
let settings = &paths.project_settings_file;
let Some(project_dir) = settings.parent().and_then(Path::parent) else {
anyhow::bail!(
"project settings path has no project directory: {}",
settings.display()
);
};
let marker_dir = project_dir.join(".magi-code");
let canonical_project = project_dir.canonicalize().with_context(|| {
format!(
"failed to canonicalize project dir {}",
project_dir.display()
)
})?;
if marker_dir.exists() {
let canonical_marker = marker_dir.canonicalize().with_context(|| {
format!(
"failed to canonicalize project config dir {}",
marker_dir.display()
)
})?;
if !canonical_marker.starts_with(&canonical_project) {
anyhow::bail!("project config dir escapes cwd: {}", marker_dir.display());
}
}
if settings.exists() {
let canonical_settings = settings.canonicalize().with_context(|| {
format!(
"failed to canonicalize project settings file {}",
settings.display()
)
})?;
if !canonical_settings.starts_with(&canonical_project) {
anyhow::bail!("project settings file escapes cwd: {}", settings.display());
}
}
Ok(())
}
fn update_raw_from_settings(
raw: &mut serde_json::Value,
settings: &Settings,
scope: SettingsScope,
) -> anyhow::Result<()> {
let openai_codex = serde_json::to_value(&settings.openai_codex)?;
let selected_model = serde_json::to_value(&settings.selected_model)?;
let custom = serde_json::to_value(&settings.custom_providers)?;
let mcp_servers = serde_json::to_value(&settings.mcp_servers)?;
let lsp = serde_json::to_value(&settings.lsp)?;
let session_titles = serde_json::to_value(&settings.session_titles)?;
let compaction = serde_json::to_value(&settings.compaction)?;
let context = serde_json::to_value(&settings.context)?;
let tools = tools_value_preserving_unknowns(raw, &settings.tools)?;
let hooks = hooks_value_preserving_unknowns(raw, &settings.hooks)?;
let preserve_default_hooks = settings.hooks.is_default() && hooks_has_unknown_fields(raw);
let instructions = serde_json::to_value(&settings.instructions)?;
let skills = serde_json::to_value(&settings.skills)?;
let subagents = serde_json::to_value(&settings.subagents)?;
let models = serde_json::to_value(&settings.models)?;
let integrations = serde_json::to_value(&settings.integrations)?;
let tui = serde_json::to_value(&settings.tui)?;
let provider_stream = serde_json::to_value(&settings.provider_stream)?;
let ttsr = serde_json::to_value(&settings.ttsr)?;
let selected_primary_agent = serde_json::to_value(&settings.selected_primary_agent)?;
let preserve_tools = raw_group_has_unknown_fields(raw, "tools", KNOWN_TOOL_KEYS);
let preserve_subagents = raw_group_has_unknown_fields(raw, "subagents", KNOWN_SUBAGENTS_KEYS);
let preserve_models = raw_group_has_unknown_fields(raw, "models", KNOWN_MODELS_KEYS);
let object = raw.as_object_mut().expect("settings raw object");
for legacy_key in [
"provider",
"model",
"thinking_level",
"herdr",
"disabled_skills",
] {
object.remove(legacy_key);
}
if settings.selected_model.is_default() {
object.remove("selected_model");
} else {
object.insert("selected_model".to_string(), selected_model);
}
if settings.openai_codex.is_default() {
object.remove("openai_codex");
} else {
object.insert("openai_codex".to_string(), openai_codex);
}
if let Some(no_color) = settings.no_color {
object.insert("no_color".to_string(), serde_json::to_value(no_color)?);
} else {
object.remove("no_color");
}
if settings.file_autocomplete_respects_gitignore {
object.remove("file_autocomplete_respects_gitignore");
} else {
object.insert(
"file_autocomplete_respects_gitignore".to_string(),
serde_json::to_value(settings.file_autocomplete_respects_gitignore)?,
);
}
object.insert("context".to_string(), context);
if settings.custom_providers.is_empty() {
object.remove("custom_providers");
} else {
object.insert("custom_providers".to_string(), custom);
}
if settings.mcp_servers.is_empty() {
object.remove("mcp_servers");
} else {
object.insert("mcp_servers".to_string(), mcp_servers);
}
if settings.lsp.is_default() {
object.remove("lsp");
} else {
object.insert("lsp".to_string(), lsp);
}
if settings.session_titles.is_default() {
object.remove("session_titles");
} else {
object.insert("session_titles".to_string(), session_titles);
}
if settings.compaction.is_default() {
object.remove("compaction");
} else {
object.insert("compaction".to_string(), compaction);
}
if settings.tools.is_default() && !preserve_tools {
object.remove("tools");
} else {
object.insert("tools".to_string(), tools);
}
if settings.hooks.is_default() && !preserve_default_hooks {
object.remove("hooks");
} else {
object.insert("hooks".to_string(), hooks);
}
if settings.instructions.is_default() {
object.remove("instructions");
} else {
object.insert("instructions".to_string(), instructions);
}
if settings.skills.is_default() {
object.remove("skills");
} else {
object.insert("skills".to_string(), skills);
}
if settings.subagents.is_default() && !preserve_subagents {
object.remove("subagents");
} else {
object.insert("subagents".to_string(), subagents);
}
if settings.models.is_default() && !preserve_models {
object.remove("models");
} else {
object.insert("models".to_string(), models);
}
if settings.integrations.is_default() {
object.remove("integrations");
} else {
object.insert("integrations".to_string(), integrations);
}
if settings.tui.is_default() {
object.remove("tui");
} else {
object.insert("tui".to_string(), tui);
}
if settings.provider_stream.is_default() {
object.remove("provider_stream");
} else {
object.insert("provider_stream".to_string(), provider_stream);
}
if settings.ttsr.is_default() {
object.remove("ttsr");
} else {
object.insert("ttsr".to_string(), ttsr);
}
if settings.selected_primary_agent.is_some() || object.contains_key("selected_primary_agent") {
object.insert("selected_primary_agent".to_string(), selected_primary_agent);
}
if let Some(cache_ttl) = settings.anthropic_cache_ttl {
object.insert(
"anthropic_cache_ttl".to_string(),
serde_json::to_value(cache_ttl)?,
);
} else {
object.remove("anthropic_cache_ttl");
}
if scope == SettingsScope::Global {
insert_default_schema_ref_if_absent(raw);
}
Ok(())
}
pub(crate) fn read_settings(paths: &McPaths) -> anyhow::Result<Settings> {
let mut raw = read_settings_json_or_empty(&paths.settings_file)?;
let local_path = paths
.local_settings_file
.as_ref()
.filter(|path| path.exists())
.unwrap_or(&paths.project_settings_file);
if local_path.exists() {
let local = read_settings_json_or_empty(local_path)?;
deep_merge_json(&mut raw, &local);
}
let settings: Settings = serde_json::from_value(raw)?;
validate_settings(&settings)?;
Ok(settings)
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn write_settings(paths: &McPaths, settings: &Settings) -> anyhow::Result<()> {
validate_settings(settings)?;
fs::create_dir_all(&paths.root)?;
let settings_lock = settings_file_lock(&paths.settings_file)?;
let _settings_guard = settings_lock
.lock()
.map_err(|_| anyhow::anyhow!("settings lock was poisoned"))?;
let _file_guard = CrossProcessFileLock::acquire(&paths.settings_file)?;
let mut raw = serde_json::to_value(settings)?;
insert_default_schema_ref_if_absent(&mut raw);
atomic_write(
&paths.settings_file,
serde_json::to_string_pretty(&raw)?.as_bytes(),
)?;
Ok(())
}
fn preserve_explicit_empty_disabled_list(
paths: &McPaths,
scope: SettingsScope,
kind: SettingsListKind,
) -> anyhow::Result<()> {
let target = settings_path_for_scope(paths, scope);
let settings_lock = settings_file_lock(&target)?;
let _settings_guard = settings_lock
.lock()
.map_err(|_| anyhow::anyhow!("settings lock was poisoned"))?;
let _file_guard = CrossProcessFileLock::acquire(&target)?;
let mut raw = read_settings_json_or_empty(&target)?;
if !raw.is_object() {
raw = serde_json::json!({});
}
let (group, key) = match kind {
SettingsListKind::Skills => ("skills", "disabled"),
SettingsListKind::Tools => ("tools", "disabled"),
SettingsListKind::Subagents => ("subagents", "disabled"),
SettingsListKind::Models => ("models", "disabled"),
};
let object = raw.as_object_mut().expect("settings raw object");
let group_value = object
.entry(group.to_string())
.or_insert_with(|| serde_json::json!({}));
if !group_value.is_object() {
*group_value = serde_json::json!({});
}
group_value
.as_object_mut()
.expect("settings group object")
.insert(key.to_string(), serde_json::Value::Array(Vec::new()));
atomic_write(&target, serde_json::to_string_pretty(&raw)?.as_bytes())?;
Ok(())
}
const KNOWN_TOOL_KEYS: &[&str] = &[
"read",
"view_image",
"hash_edit",
"write",
"grep",
"ffgrep",
"find",
"fffind",
"list_files",
"repo_map",
"ast_grep",
"bash",
"subagents",
"parallel_subagents",
"output_compression",
"disabled",
];
const KNOWN_SUBAGENTS_KEYS: &[&str] = &["disabled", "schema_validation_max_retries"];
const KNOWN_MODELS_KEYS: &[&str] = &["disabled"];
fn tools_value_preserving_unknowns(
raw: &serde_json::Value,
tools: &ToolSettings,
) -> anyhow::Result<serde_json::Value> {
group_value_preserving_unknowns(raw, "tools", tools)
}
fn group_value_preserving_unknowns<T: Serialize>(
raw: &serde_json::Value,
group: &str,
value: &T,
) -> anyhow::Result<serde_json::Value> {
let mut next_value = serde_json::to_value(value)?;
let Some(original) = raw.get(group).and_then(serde_json::Value::as_object) else {
return Ok(next_value);
};
let Some(next) = next_value.as_object_mut() else {
return Ok(next_value);
};
for (key, value) in original {
next.entry(key.clone()).or_insert_with(|| value.clone());
}
Ok(next_value)
}
fn raw_group_has_unknown_fields(raw: &serde_json::Value, group: &str, known_keys: &[&str]) -> bool {
raw.get(group)
.and_then(serde_json::Value::as_object)
.is_some_and(|object| object.keys().any(|key| !known_keys.contains(&key.as_str())))
}
fn hooks_value_preserving_unknowns(
raw: &serde_json::Value,
hooks: &HookSettings,
) -> anyhow::Result<serde_json::Value> {
let mut hooks_value = serde_json::to_value(hooks)?;
let Some(original) = raw.get("hooks").and_then(serde_json::Value::as_object) else {
return Ok(hooks_value);
};
let Some(next) = hooks_value.as_object_mut() else {
return Ok(hooks_value);
};
for (key, value) in original {
next.entry(key.clone()).or_insert_with(|| value.clone());
}
Ok(hooks_value)
}
fn hooks_has_unknown_fields(raw: &serde_json::Value) -> bool {
const KNOWN_HOOK_KEYS: &[&str] = &[
"enabled",
"show_in_tui",
"payload",
"timeout_seconds",
"stdout_max_bytes",
"stderr_max_bytes",
"failure_policy",
"provider_context_injection",
"provider_context_max_bytes",
"injected_content",
"before_tool",
"after_tool",
"after_assistant",
"after_reasoning",
];
raw.get("hooks")
.and_then(serde_json::Value::as_object)
.is_some_and(|hooks| {
hooks
.keys()
.any(|key| !KNOWN_HOOK_KEYS.contains(&key.as_str()))
})
}
fn deep_merge_json(base: &mut serde_json::Value, override_val: &serde_json::Value) {
match (base, override_val) {
(serde_json::Value::Object(base_object), serde_json::Value::Object(override_object)) => {
for (key, value) in override_object {
match base_object.get_mut(key) {
Some(base_value) => deep_merge_json(base_value, value),
None => {
base_object.insert(key.clone(), value.clone());
}
}
}
}
(base_value, override_value) => *base_value = override_value.clone(),
}
}
fn read_settings_json_or_empty(path: &Path) -> anyhow::Result<serde_json::Value> {
let text = read_settings_text_or_empty(path)?;
serde_json::from_str(&text)
.with_context(|| format!("failed to parse settings JSON from {}", path.display()))
}
fn read_settings_text_or_empty(path: &Path) -> anyhow::Result<String> {
match fs::read_to_string(path) {
Ok(text) => Ok(text),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok("{}".to_string()),
Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
}
}
fn settings_file_lock(path: &Path) -> anyhow::Result<std::sync::Arc<std::sync::Mutex<()>>> {
in_process_file_lock(path, "settings")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::PathBuf;
fn read_settings_value(paths: &McPaths) -> serde_json::Value {
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap()
}
fn parse_validated_settings(raw: &str) -> anyhow::Result<Settings> {
let settings = serde_json::from_str(raw)?;
validate_settings(&settings)?;
Ok(settings)
}
#[test]
fn subagents_schema_retry_settings_default_bounds_and_update_preservation() {
let default_settings: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(
default_settings.subagents.schema_validation_max_retries(),
DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES
);
let too_high =
parse_validated_settings(r#"{"subagents":{"schema_validation_max_retries":6}}"#)
.unwrap_err()
.to_string();
assert!(too_high.contains("subagents.schema_validation_max_retries"));
let mut raw = json!({
"subagents": {"schema_validation_max_retries": 5},
"selected_primary_agent": "old"
});
let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
settings.selected_primary_agent = Some("new".to_string());
update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
assert_eq!(raw["subagents"]["schema_validation_max_retries"], 5);
assert!(KNOWN_SUBAGENTS_KEYS.contains(&"schema_validation_max_retries"));
}
#[test]
fn settings_ttsr_defaults_disabled_and_explicit_true_survives() {
let default_settings: Settings = serde_json::from_str("{}").unwrap();
assert!(!default_settings.ttsr.enabled);
let explicit_enabled: Settings =
serde_json::from_str(r#"{"ttsr":{"enabled":true}}"#).unwrap();
assert!(explicit_enabled.ttsr.enabled);
}
#[test]
fn settings_ttsr_rejects_invalid_regex_and_empty_reminder() {
let invalid_regex =
parse_validated_settings(r#"{"ttsr":{"rules":[{"pattern":"(","reminder":"stop"}]}}"#)
.unwrap_err()
.to_string();
assert!(invalid_regex.contains("ttsr.rules[0].pattern"));
let empty_reminder = parse_validated_settings(
r#"{"ttsr":{"rules":[{"pattern":"danger","reminder":" "}]}}"#,
)
.unwrap_err()
.to_string();
assert!(empty_reminder.contains("ttsr.rules[0].reminder"));
}
#[test]
fn ttsr_settings_survive_update_raw_from_settings() {
let mut raw = json!({
"ttsr": {"rules": [{"pattern": "danger", "reminder": "stop"}]},
"selected_primary_agent": "old"
});
let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
settings.selected_primary_agent = Some("new".to_string());
update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
assert_eq!(raw["ttsr"]["rules"][0]["pattern"], "danger");
assert_eq!(raw["selected_primary_agent"], "new");
}
#[test]
fn provider_stream_settings_defaults_parse_validate_and_resolve() {
let absent: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(absent.provider_stream, ProviderStreamSettings::default());
assert_eq!(
absent.provider_stream.semantic_progress_timeout(),
Duration::from_secs(DEFAULT_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS)
);
assert_eq!(
absent.provider_stream.subagent_semantic_progress_timeout(),
Duration::from_secs(DEFAULT_SUBAGENT_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS)
);
let configured: Settings = serde_json::from_str(
r#"{"provider_stream":{"semantic_progress_timeout_seconds":90,"subagent_semantic_progress_timeout_seconds":180}}"#,
)
.unwrap();
validate_settings(&configured).unwrap();
assert_eq!(
configured.provider_stream.semantic_progress_timeout(),
Duration::from_secs(90)
);
assert_eq!(
configured
.provider_stream
.subagent_semantic_progress_timeout(),
Duration::from_secs(180)
);
let too_low = parse_validated_settings(
r#"{"provider_stream":{"semantic_progress_timeout_seconds":0}}"#,
)
.unwrap_err()
.to_string();
assert!(too_low.contains("provider_stream.semantic_progress_timeout_seconds"));
let too_high = parse_validated_settings(
r#"{"provider_stream":{"subagent_semantic_progress_timeout_seconds":601}}"#,
)
.unwrap_err()
.to_string();
assert!(too_high.contains("provider_stream.subagent_semantic_progress_timeout_seconds"));
}
#[test]
fn write_file_if_changed_skips_identical_content() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("schema.json");
assert!(write_file_if_changed(&path, b"one").unwrap());
assert!(!write_file_if_changed(&path, b"one").unwrap());
assert!(write_file_if_changed(&path, b"two").unwrap());
assert_eq!(fs::read(&path).unwrap(), b"two");
}
#[test]
fn settings_schema_generation_writes_state_schema_with_core_properties() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.state).unwrap();
ensure_settings_schema_files(&paths).unwrap();
let schema_text = fs::read_to_string(paths.state.join("settings.schema.json")).unwrap();
let schema: serde_json::Value = serde_json::from_str(&schema_text).unwrap();
let schema_text = schema.to_string();
for key in [
"selected_model",
"tools",
"hooks",
"custom_providers",
"mcp_servers",
"context",
"tui",
"skills",
"subagents",
"models",
"instructions",
"openai_codex",
"anthropic_cache_ttl",
"lsp",
"ttsr",
] {
assert!(schema_text.contains(key), "schema missing {key}");
}
assert_eq!(
read_settings_value(&paths)["$schema"],
SETTINGS_SCHEMA_RELATIVE_REF
);
assert_eq!(read_settings(&paths).unwrap(), Settings::default());
}
#[test]
fn openai_codex_text_verbosity_settings_parse_default_invalid_and_schema() {
let absent: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(absent.openai_codex.text_verbosity, TextVerbosity::Low);
assert!(
serde_json::to_value(&absent)
.unwrap()
.get("openai_codex")
.is_none()
);
let medium: Settings =
serde_json::from_str(r#"{"openai_codex":{"text_verbosity":"medium"}}"#).unwrap();
assert_eq!(medium.openai_codex.text_verbosity, TextVerbosity::Medium);
assert_eq!(
serde_json::to_value(&medium).unwrap()["openai_codex"]["text_verbosity"],
"medium"
);
let error =
serde_json::from_str::<Settings>(r#"{"openai_codex":{"text_verbosity":"verbose"}}"#)
.unwrap_err()
.to_string();
assert!(error.contains("expected one of"), "{error}");
let schema = serde_json::to_string(&schemars::schema_for!(Settings)).unwrap();
for value in ["openai_codex", "text_verbosity", "low", "medium", "high"] {
assert!(schema.contains(value), "schema missing {value}: {schema}");
}
}
#[test]
fn openai_responses_text_verbosity_resolves_shared_and_capability_rules() {
let mut settings: Settings = serde_json::from_str(
r#"{
"openai_responses": {"text_verbosity": "medium"},
"openai_codex": {"text_verbosity": "high"},
"custom_providers": {
"capable": {
"label": "Capable",
"base_url": "http://localhost:8080/v1",
"use_responses_endpoint": true,
"supports_text_verbosity": true
},
"unsupported": {
"label": "Unsupported",
"base_url": "http://localhost:8081/v1",
"use_responses_endpoint": true
}
}
}"#,
)
.unwrap();
assert_eq!(
settings.text_verbosity_for(crate::providers::OPENAI_CODEX_PROVIDER),
Some(TextVerbosity::Medium)
);
assert_eq!(
settings.text_verbosity_for("capable"),
Some(TextVerbosity::Medium)
);
assert_eq!(settings.text_verbosity_for("unsupported"), None);
assert_eq!(settings.text_verbosity_for("missing"), None);
settings.openai_responses.text_verbosity = None;
assert_eq!(
settings.text_verbosity_for(crate::providers::OPENAI_CODEX_PROVIDER),
Some(TextVerbosity::High)
);
}
#[test]
fn anthropic_cache_ttl_settings_parse_serialize_and_validate_schema() {
let settings: Settings = serde_json::from_str(r#"{"anthropic_cache_ttl":"1h"}"#).unwrap();
assert_eq!(
settings.anthropic_cache_ttl,
Some(AnthropicCacheTtl::OneHour)
);
assert_eq!(
serde_json::to_value(&settings).unwrap()["anthropic_cache_ttl"],
"1h"
);
let five_minute_settings: Settings =
serde_json::from_str(r#"{"anthropic_cache_ttl":"5m"}"#).unwrap();
assert_eq!(
five_minute_settings.anthropic_cache_ttl,
Some(AnthropicCacheTtl::FiveMinutes)
);
assert_eq!(
serde_json::to_value(&five_minute_settings).unwrap()["anthropic_cache_ttl"],
"5m"
);
let default_settings: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(default_settings.anthropic_cache_ttl, None);
assert!(
serde_json::to_value(&default_settings)
.unwrap()
.get("anthropic_cache_ttl")
.is_none()
);
let schema = serde_json::to_string(&schemars::schema_for!(Settings)).unwrap();
assert!(schema.contains("anthropic_cache_ttl"), "{schema}");
}
#[test]
fn settings_schema_setup_inserts_schema_for_valid_settings_and_preserves_values() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
fs::write(
&paths.settings_file,
r#"{"selected_model":{"provider":"openai-codex","model":"gpt-5.5"},"future_setting":{"keep":true}}"#,
)
.unwrap();
ensure_settings_schema_files(&paths).unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["$schema"], SETTINGS_SCHEMA_RELATIVE_REF);
assert_eq!(value["selected_model"]["provider"], "openai-codex");
assert_eq!(value["selected_model"]["model"], "gpt-5.5");
assert_eq!(value["future_setting"]["keep"], true);
}
#[test]
fn settings_schema_setup_preserves_existing_custom_schema() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
fs::write(
&paths.settings_file,
r#"{"$schema":"https://example.test/custom.schema.json","selected_model":{"provider":"openai-codex"}}"#,
)
.unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(
read_settings_value(&paths)["$schema"],
"https://example.test/custom.schema.json"
);
}
#[test]
fn settings_schema_setup_leaves_invalid_settings_unchanged() {
for original in [
"not json",
"[]",
r#"{"selected_model":{"thinking_level":"maximum"}}"#,
r#"{"custom_providers":{"bad":{"label":"Bad","base_url":"https://provider.test/v1/models"}}}"#,
] {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
fs::write(&paths.settings_file, original).unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), original);
}
}
#[test]
fn settings_write_paths_emit_or_preserve_schema_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
write_settings(
&paths,
&Settings {
selected_model: SelectedModelSettings {
provider: Some("openai-codex".to_string()),
..SelectedModelSettings::default()
},
..Settings::default()
},
)
.unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["$schema"], SETTINGS_SCHEMA_RELATIVE_REF);
assert_eq!(value["selected_model"]["provider"], "openai-codex");
fs::write(
&paths.settings_file,
json!({
"$schema": "https://example.test/custom.schema.json",
"future_setting": true,
"selected_model": {"provider":"old-provider"}
})
.to_string(),
)
.unwrap();
set_selected_model(&paths, "new-provider", "new-model").unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["$schema"], "https://example.test/custom.schema.json");
assert_eq!(value["future_setting"], true);
assert_eq!(value["selected_model"]["provider"], "new-provider");
assert_eq!(value["selected_model"]["model"], "new-model");
}
#[test]
fn settings_update_round_trips_top_level_scalar_context_fields() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, r#"{"future_setting":true}"#).unwrap();
update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.no_color = Some(true);
settings.file_autocomplete_respects_gitignore = false;
settings.context = serde_json::from_value(json!({"max_tokens": 128000})).unwrap();
})
.unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["future_setting"], true);
assert_eq!(value["no_color"], true);
assert_eq!(value["file_autocomplete_respects_gitignore"], false);
assert_eq!(value["context"]["max_tokens"], 128000);
update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.no_color = None;
settings.file_autocomplete_respects_gitignore = true;
settings.context = None;
})
.unwrap();
let value = read_settings_value(&paths);
assert!(value.get("no_color").is_none());
assert!(value.get("file_autocomplete_respects_gitignore").is_none());
assert_eq!(value["context"], serde_json::Value::Null);
}
#[test]
fn settings_update_rejects_invalid_custom_provider_mutation_without_write() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"future_setting":true,"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1"}}}"#,
)
.unwrap();
let before = fs::read_to_string(&paths.settings_file).unwrap();
let error = update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.custom_providers.get_mut("local").unwrap().base_url =
"https://user:password@example.test/v1".to_string();
})
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("userinfo"), "{error}");
assert!(!error.contains("password"), "{error}");
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), before);
}
#[test]
fn write_settings_rejects_invalid_custom_provider_settings() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let settings = Settings {
custom_providers: BTreeMap::from([(
"local".to_string(),
CustomProviderConfig {
label: "Local".to_string(),
base_url: "https://user:password@example.test/v1".to_string(),
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
},
)]),
..Settings::default()
};
let error = write_settings(&paths, &settings).unwrap_err().to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("userinfo"), "{error}");
assert!(!error.contains("password"), "{error}");
assert!(!paths.settings_file.exists());
}
#[test]
fn upsert_custom_provider_rejects_invalid_config_without_write() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
write_settings(&paths, &Settings::default()).unwrap();
let before = fs::read_to_string(&paths.settings_file).unwrap();
let error = upsert_custom_provider(
&paths,
"local",
CustomProviderConfig {
label: "Local".to_string(),
base_url: "https://user:password@example.test/v1".to_string(),
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
},
)
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("userinfo"), "{error}");
assert!(!error.contains("password"), "{error}");
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), before);
}
#[test]
fn context_model_overrides_parse_serialize_and_validate_keys() {
let settings: Settings = serde_json::from_str(
r#"{"context":{"max_tokens":128000,"model_overrides":{"openai-codex/gpt-5.5":{"max_tokens":400000,"reserve_tokens":32768},"zai/Qwen/Qwen3":{"keep_recent_tokens":50000}}}}"#,
)
.unwrap();
let budget = settings.context.as_ref().unwrap();
let codex = budget.model_overrides.get("openai-codex/gpt-5.5").unwrap();
assert_eq!(codex.max_tokens, Some(400_000));
assert_eq!(codex.reserve_tokens, Some(32_768));
assert_eq!(codex.keep_recent_tokens, None);
assert_eq!(
budget
.model_overrides
.get("zai/Qwen/Qwen3")
.unwrap()
.keep_recent_tokens,
Some(50_000)
);
let serialized = serde_json::to_value(&settings).unwrap();
assert_eq!(
serialized["context"]["model_overrides"]["openai-codex/gpt-5.5"]["max_tokens"],
400000
);
for raw in [
r#"{"context":{"model_overrides":{"missing-slash":{"max_tokens":1}}}}"#,
r#"{"context":{"model_overrides":{"/missing-provider":{"max_tokens":1}}}}"#,
r#"{"context":{"model_overrides":{"provider/":{"max_tokens":1}}}}"#,
r#"{"context":{"model_overrides":{"provider /model":{"max_tokens":1}}}}"#,
r#"{"context":{"model_overrides":{"provider/model":{}}}}"#,
] {
assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
}
}
#[test]
fn settings_deserialize_ignores_schema_metadata() {
let settings: Settings = serde_json::from_str(
r#"{"$schema":"./state/settings.schema.json","selected_model":{"provider":"openai-codex"}}"#,
)
.unwrap();
assert_eq!(
settings.selected_model.provider.as_deref(),
Some("openai-codex")
);
}
#[test]
fn instructions_settings_deserialize_additional_markdown_paths() {
let settings: Settings = serde_json::from_str(
r#"{"instructions":{"additional_markdown_paths":["/opt/team.md","/Users/test/review.md"]}}"#,
)
.unwrap();
assert!(!settings.instructions.subdir_discovery);
assert_eq!(
settings.instructions.additional_markdown_paths,
vec![
PathBuf::from("/opt/team.md"),
PathBuf::from("/Users/test/review.md")
]
);
}
#[test]
fn instructions_settings_deserialize_subdir_discovery() {
let default_settings: Settings = serde_json::from_str("{}").unwrap();
assert!(!default_settings.instructions.subdir_discovery);
let enabled: Settings =
serde_json::from_str(r#"{"instructions":{"subdir_discovery":true}}"#).unwrap();
assert!(enabled.instructions.subdir_discovery);
let serialized = serde_json::to_value(&enabled).unwrap();
assert_eq!(serialized["instructions"]["subdir_discovery"], true);
let schema = serde_json::to_string(&schemars::schema_for!(Settings)).unwrap();
assert!(schema.contains("subdir_discovery"), "{schema}");
}
#[test]
fn skills_settings_deserialize_additional_paths() {
let settings: Settings = serde_json::from_str(
r#"{"skills":{"additional_paths":["/opt/magi-skills","/Users/test/skills"]}}"#,
)
.unwrap();
assert_eq!(
settings.skills.additional_paths,
vec![
PathBuf::from("/opt/magi-skills"),
PathBuf::from("/Users/test/skills")
]
);
}
#[test]
fn normalized_settings_read_canonical_legacy_and_conflicts() {
let canonical: Settings = serde_json::from_str(
r#"{
"selected_model":{"provider":"canonical-provider","model":"canonical-model","thinking_level":"high"},
"skills":{"disabled":["review"]},
"integrations":{"herdr":{"enabled":true}}
}"#,
)
.unwrap();
assert_eq!(
canonical.selected_model.provider.as_deref(),
Some("canonical-provider")
);
assert_eq!(
canonical.selected_model.model.as_deref(),
Some("canonical-model")
);
assert_eq!(
canonical.selected_model.thinking_level,
Some(ThinkingLevel::High)
);
assert_eq!(canonical.skills.disabled, vec!["review"]);
assert!(canonical.integrations.herdr.enabled);
let legacy: Settings = serde_json::from_str(
r#"{
"provider":"legacy-provider",
"model":"legacy-model",
"thinking_level":"medium",
"disabled_skills":["plan"],
"herdr":{"enabled":true}
}"#,
)
.unwrap();
assert_eq!(
legacy.selected_model.provider.as_deref(),
Some("legacy-provider")
);
assert_eq!(legacy.selected_model.model.as_deref(), Some("legacy-model"));
assert_eq!(
legacy.selected_model.thinking_level,
Some(ThinkingLevel::Medium)
);
assert_eq!(legacy.skills.disabled, vec!["plan"]);
assert!(legacy.integrations.herdr.enabled);
let conflict: Settings = serde_json::from_str(
r#"{
"selected_model":{"provider":"canonical-provider","model":"canonical-model","thinking_level":"low"},
"provider":"legacy-provider",
"model":"legacy-model",
"thinking_level":"high",
"skills":{"disabled":[]},
"disabled_skills":["legacy-skill"],
"integrations":{"herdr":{"enabled":false}},
"herdr":{"enabled":true}
}"#,
)
.unwrap();
assert_eq!(
conflict.selected_model.provider.as_deref(),
Some("canonical-provider")
);
assert_eq!(
conflict.selected_model.model.as_deref(),
Some("canonical-model")
);
assert_eq!(
conflict.selected_model.thinking_level,
Some(ThinkingLevel::Low)
);
assert!(conflict.skills.disabled.is_empty());
assert!(!conflict.integrations.herdr.enabled);
}
#[test]
fn tool_settings_accept_canonical_and_legacy_tool_keys() {
let canonical: Settings = serde_json::from_str(
r#"{"tools":{"grep":{"absolute_paths":false},"find":{"absolute_paths":false},"list_files":{"absolute_paths":false},"subagents":{"absolute_paths":false,"max_depth":2}}}"#,
)
.unwrap();
assert!(!canonical.tools.grep.absolute_paths);
assert!(!canonical.tools.find.absolute_paths);
assert!(!canonical.tools.list_files.absolute_paths);
assert!(!canonical.tools.subagents.absolute_paths);
assert_eq!(canonical.tools.subagents.max_depth, 2);
let legacy: Settings = serde_json::from_str(
r#"{"tools":{"ffgrep":{"absolute_paths":false},"fffind":{"absolute_paths":false},"list_files":{"absolute_paths":false},"parallel_subagents":{"absolute_paths":false,"max_depth":3}}}"#,
)
.unwrap();
assert!(!legacy.tools.grep.absolute_paths);
assert!(!legacy.tools.find.absolute_paths);
assert!(!legacy.tools.list_files.absolute_paths);
assert!(!legacy.tools.subagents.absolute_paths);
assert_eq!(legacy.tools.subagents.max_depth, 3);
}
#[test]
fn normalized_settings_default_serialization_omits_empty_groups() {
let value = serde_json::to_value(Settings::default()).unwrap();
assert!(value.get("selected_model").is_none());
assert!(value.get("integrations").is_none());
assert!(value.get("instructions").is_none());
assert!(value.get("skills").is_none());
}
#[test]
fn normalized_settings_update_preserves_nested_unknowns_and_removes_legacy_keys() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{
"future_setting":true,
"provider":"legacy-provider",
"model":"legacy-model",
"thinking_level":"high",
"disabled_skills":["legacy-skill"],
"herdr":{"enabled":true,"legacy_future":"keep"},
"selected_model":{"provider":"canonical-provider","future":"keep"},
"instructions":{"additional_markdown_paths":["/opt/team.md"],"future":"keep"},
"skills":{"additional_paths":["/opt/skills"],"future":"keep"},
"integrations":{"future":"keep","herdr":{"enabled":false,"future":"keep"}},
"tui":{"future":"keep"}
}"#,
)
.unwrap();
set_selected_model(&paths, "new-provider", "new-model").unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["future_setting"], true);
assert_eq!(value["selected_model"]["provider"], "new-provider");
assert_eq!(value["selected_model"]["model"], "new-model");
assert_eq!(value["selected_model"]["thinking_level"], "high");
assert_eq!(value["selected_model"]["future"], "keep");
assert_eq!(
value["instructions"]["additional_markdown_paths"][0],
"/opt/team.md"
);
assert_eq!(value["instructions"]["future"], "keep");
assert_eq!(value["skills"]["disabled"][0], "legacy-skill");
assert_eq!(value["skills"]["future"], "keep");
assert_eq!(value["integrations"]["future"], "keep");
assert_eq!(value["integrations"]["herdr"]["enabled"], false);
assert_eq!(value["integrations"]["herdr"]["future"], "keep");
assert_eq!(value["tui"]["future"], "keep");
for legacy_key in [
"provider",
"model",
"thinking_level",
"disabled_skills",
"herdr",
] {
assert!(
value.get(legacy_key).is_none(),
"legacy key survived: {legacy_key}"
);
}
}
#[test]
fn settings_update_treats_repo_map_as_known_tool_key() {
let mut raw = json!({
"tools": {"repo_map": {"absolute_paths": true}},
"selected_primary_agent": "old"
});
let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
settings.selected_primary_agent = Some("new".to_string());
update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
assert!(raw.get("tools").is_none());
assert_eq!(raw["selected_primary_agent"], "new");
assert!(KNOWN_TOOL_KEYS.contains(&"repo_map"));
}
#[test]
fn hook_settings_preserve_unknown_fields_on_settings_update() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"hooks":{"future":{"keep":true}}}"#,
)
.unwrap();
set_selected_model(&paths, "provider", "model").unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["hooks"]["future"]["keep"], true);
assert_eq!(value["selected_model"]["provider"], "provider");
assert_eq!(value["selected_model"]["model"], "model");
}
#[test]
fn hook_settings_default_without_unknown_fields_is_omitted_on_settings_update() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, r#"{}"#).unwrap();
set_selected_model(&paths, "provider", "model").unwrap();
let value = read_settings_value(&paths);
assert!(value.get("hooks").is_none());
}
#[test]
fn hook_settings_known_fields_override_old_raw_values_on_settings_update() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"hooks":{"enabled":false,"future":{"keep":true}}}"#,
)
.unwrap();
update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.hooks.enabled = true;
})
.unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["hooks"]["enabled"], true);
assert_eq!(value["hooks"]["future"]["keep"], true);
}
#[test]
fn settings_read_defaults_only_when_file_is_missing() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(read_settings(&paths).unwrap(), Settings::default());
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir(&paths.settings_file).unwrap();
let error = read_settings(&paths).unwrap_err().to_string();
assert!(error.contains("failed to read"), "{error}");
}
fn paths_with_local_settings(temp: &tempfile::TempDir) -> (McPaths, PathBuf) {
let mut paths = McPaths::from_root(temp.path().join("mc"));
let local_settings = temp.path().join("project/.magi-code/settings.json");
paths.project_settings_file = local_settings.clone();
paths.local_settings_file = Some(local_settings.clone());
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(local_settings.parent().unwrap()).unwrap();
(paths, local_settings)
}
#[test]
fn deep_merge_json_recurses_objects_and_replaces_non_objects() {
let mut base = json!({
"object": {"keep": true, "replace": {"old": true}},
"array": ["a"],
"scalar": true
});
let override_val = json!({
"object": {"replace": {"new": true}},
"array": ["b"],
"scalar": null
});
deep_merge_json(&mut base, &override_val);
assert_eq!(base["object"]["keep"], true);
assert_eq!(base["object"]["replace"], json!({"old": true, "new": true}));
assert_eq!(base["array"], json!(["b"]));
assert_eq!(base["scalar"], serde_json::Value::Null);
}
#[test]
fn read_settings_local_scalar_overrides_global() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
fs::write(&local_settings, r#"{"no_color":false}"#).unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(settings.no_color, Some(false));
}
#[test]
fn read_settings_local_omission_keeps_global_nested_values() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{"tools":{"read":{"absolute_paths":false}}}"#,
)
.unwrap();
fs::write(&local_settings, r#"{"no_color":true}"#).unwrap();
let settings = read_settings(&paths).unwrap();
assert!(!settings.tools.read.absolute_paths);
assert_eq!(settings.no_color, Some(true));
}
#[test]
fn read_settings_local_map_union_and_key_override() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{"mcp_servers":{"server1":{"type":"stdio","command":"global"}}}"#,
)
.unwrap();
fs::write(
&local_settings,
r#"{"mcp_servers":{"server1":{"type":"stdio","command":"local"},"server2":{"type":"stdio","command":"second"}}}"#,
)
.unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(settings.mcp_servers.len(), 2);
let McpServerConfig::Stdio(server1) = settings.mcp_servers.get("server1").unwrap() else {
panic!("expected stdio server1");
};
let McpServerConfig::Stdio(server2) = settings.mcp_servers.get("server2").unwrap() else {
panic!("expected stdio server2");
};
assert_eq!(server1.command, "local");
assert_eq!(server2.command, "second");
}
#[test]
fn read_settings_local_vec_replaces_global_vec() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{"skills":{"additional_paths":["a"]}}"#,
)
.unwrap();
fs::write(&local_settings, r#"{"skills":{"additional_paths":["b"]}}"#).unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(settings.skills.additional_paths, vec![PathBuf::from("b")]);
}
#[test]
fn scoped_project_update_creates_exact_cwd_settings_and_preserves_unknowns() {
let temp = tempfile::TempDir::new().unwrap();
let mut paths = McPaths::from_root(temp.path().join("mc"));
paths.project_settings_file = temp.path().join("project/.magi-code/settings.json");
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
fs::write(
&paths.project_settings_file,
r#"{"future":true,"tools":{"future_tool":true},"subagents":{"future_subagent":true}}"#,
)
.unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
set_subagent_profile_disabled(&paths, SettingsScope::Project, "reviewer", true).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap())
.unwrap();
assert_eq!(value["future"], true);
assert_eq!(value["tools"]["future_tool"], true);
assert_eq!(value["tools"]["disabled"], json!(["bash"]));
assert_eq!(value["subagents"]["future_subagent"], true);
assert_eq!(value["subagents"]["disabled"], json!(["reviewer"]));
assert!(value.get("$schema").is_none());
}
#[test]
fn project_disabled_empty_list_overrides_global_disabled() {
let temp = tempfile::TempDir::new().unwrap();
let mut paths = McPaths::from_root(temp.path().join("mc"));
paths.project_settings_file = temp.path().join("project/.magi-code/settings.json");
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
fs::write(&paths.settings_file, r#"{"tools":{"disabled":["bash"]}}"#).unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", false).unwrap();
let local: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap())
.unwrap();
assert_eq!(local["tools"]["disabled"], json!([]));
assert!(disabled_tool_names_from_settings(&read_settings(&paths).unwrap()).is_empty());
}
#[test]
fn scoped_project_update_rejects_magi_code_symlink_escape() {
let temp = tempfile::TempDir::new().unwrap();
let mut paths = McPaths::from_root(temp.path().join("mc"));
let project = temp.path().join("project");
let outside = temp.path().join("outside");
fs::create_dir_all(&project).unwrap();
fs::create_dir_all(&outside).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, project.join(".magi-code")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&outside, project.join(".magi-code")).unwrap();
paths.project_settings_file = project.join(".magi-code/settings.json");
let error = set_skill_disabled_for_scope(&paths, SettingsScope::Project, "review", true)
.unwrap_err()
.to_string();
assert!(error.contains("escapes cwd"), "{error}");
}
#[test]
fn project_modal_scope_inherits_effective_until_local_list_exists() {
let temp = tempfile::TempDir::new().unwrap();
let mut paths = McPaths::from_root(temp.path().join("mc"));
paths.project_settings_file = temp.path().join("project/.magi-code/settings.json");
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, r#"{"skills":{"disabled":["audit"]}}"#).unwrap();
let inherited = disabled_names_for_modal_scope(
&paths,
SettingsScope::Project,
SettingsListKind::Skills,
)
.unwrap();
assert!(inherited.contains("audit"));
assert!(!paths.project_settings_file.exists());
}
#[test]
fn project_update_does_not_serialize_full_default_tools_block() {
let temp = tempfile::TempDir::new().unwrap();
let mut paths = McPaths::from_root(temp.path().join("mc"));
paths.project_settings_file = temp.path().join("project/.magi-code/settings.json");
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap())
.unwrap();
assert_eq!(value["tools"]["disabled"], json!(["bash"]));
assert!(value["tools"].get("read").is_none());
assert!(value["tools"].get("subagents").is_none());
}
#[test]
fn read_settings_for_scope_reads_only_target_file() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
fs::write(&local_settings, r#"{"no_color":false}"#).unwrap();
assert_eq!(
read_settings_for_scope(&paths, SettingsScope::Global)
.unwrap()
.no_color,
Some(true)
);
assert_eq!(
read_settings_for_scope(&paths, SettingsScope::Project)
.unwrap()
.no_color,
Some(false)
);
}
#[test]
fn read_settings_accepts_local_only_settings() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&local_settings, r#"{"no_color":true}"#).unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(settings.no_color, Some(true));
}
#[test]
fn read_settings_keeps_global_when_local_is_empty_or_missing() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
fs::write(&local_settings, r#"{}"#).unwrap();
assert_eq!(read_settings(&paths).unwrap().no_color, Some(true));
fs::remove_file(&local_settings).unwrap();
assert_eq!(read_settings(&paths).unwrap().no_color, Some(true));
}
#[test]
fn read_settings_invalid_local_json_error_names_local_path() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
fs::write(&local_settings, "not json").unwrap();
let error = read_settings(&paths).unwrap_err().to_string();
assert!(
error.contains(&local_settings.display().to_string()),
"{error}"
);
}
#[test]
fn write_settings_writes_global_only_when_local_settings_exists() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&local_settings, r#"{"no_color":false}"#).unwrap();
let local_before = fs::read_to_string(&local_settings).unwrap();
write_settings(
&paths,
&Settings {
no_color: Some(true),
..Settings::default()
},
)
.unwrap();
assert_eq!(fs::read_to_string(&local_settings).unwrap(), local_before);
assert_eq!(read_settings_value(&paths)["no_color"], true);
}
#[test]
fn concurrent_settings_updates_preserve_independent_fields() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let left = paths.clone();
let right = paths.clone();
let left = std::thread::spawn(move || {
update_settings_preserving_unknown_top_level_fields(&left, |settings| {
settings.selected_model.provider = Some("provider-a".to_string());
})
.unwrap();
});
let right = std::thread::spawn(move || {
update_settings_preserving_unknown_top_level_fields(&right, |settings| {
settings.session_titles = SessionTitleSettings {
enabled: true,
provider: Some("title-provider".to_string()),
model: Some("title-model".to_string()),
};
})
.unwrap();
});
left.join().unwrap();
right.join().unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(
settings.selected_model.provider.as_deref(),
Some("provider-a")
);
assert!(settings.session_titles.enabled);
assert_eq!(
settings.session_titles.provider.as_deref(),
Some("title-provider")
);
assert_eq!(
settings.session_titles.model.as_deref(),
Some("title-model")
);
}
#[test]
fn mcp_servers_parse_defaults_validate_and_serialize() {
let settings: Settings = serde_json::from_str(
r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","args":["server.js"],"env":{},"enabled":true,"timeout":30}}}"#,
)
.unwrap();
let config = settings.mcp_servers.get("mock").unwrap();
match config {
McpServerConfig::Stdio(stdio) => {
assert_eq!(stdio.command, "node");
assert_eq!(stdio.args, vec!["server.js"]);
assert!(stdio.env.is_empty());
assert!(stdio.enabled);
assert_eq!(stdio.timeout, Some(30));
}
McpServerConfig::Http(_) => panic!("expected stdio config"),
}
let absent: Settings = serde_json::from_str("{}").unwrap();
assert!(absent.mcp_servers.is_empty());
let serialized = serde_json::to_value(settings).unwrap();
assert_eq!(serialized["mcp_servers"]["mock"]["type"], "stdio");
}
#[test]
fn mcp_http_servers_parse_defaults_validate_serialize_and_redact_debug() {
let settings: Settings = serde_json::from_str(
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp?token=url-secret#frag","headers":{"Authorization":"{env:MCP_TOKEN}","X-Team":"platform"}}}}"#,
)
.unwrap();
let config = settings.mcp_servers.get("remote").unwrap();
match config {
McpServerConfig::Http(http) => {
assert_eq!(
http.url,
"https://mcp.example.test/mcp?token=url-secret#frag"
);
assert!(http.enabled);
assert_eq!(http.timeout, None);
assert_eq!(http.headers["Authorization"], "{env:MCP_TOKEN}");
assert!(http.oauth.is_none());
let debug = format!("{http:?}");
assert!(debug.contains("[REDACTED]"), "{debug}");
assert!(!debug.contains("platform"), "{debug}");
assert!(!debug.contains("MCP_TOKEN"), "{debug}");
assert!(!debug.contains("url-secret"), "{debug}");
assert!(!debug.contains("token="), "{debug}");
}
McpServerConfig::Stdio(_) => panic!("expected http config"),
}
let serialized = serde_json::to_value(settings).unwrap();
assert_eq!(serialized["mcp_servers"]["remote"]["type"], "http");
}
#[test]
fn mcp_http_servers_reject_invalid_url_headers_and_timeout() {
for raw in [
r#"{"mcp_servers":{"remote":{"type":"http","url":"ftp://mcp.example.test/mcp"}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://user:pass@mcp.example.test/mcp"}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Bad:Name":"x"}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"literal"}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"{env:1BAD}"}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","timeout":301}}}"#,
] {
assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
}
for raw in [
r#"{"mcp_servers":{"local":{"type":"http","url":"http://localhost:8787/mcp"}}}"#,
r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.1:8787/mcp"}}}"#,
r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.2:8787/mcp"}}}"#,
r#"{"mcp_servers":{"local":{"type":"http","url":"http://[::1]:8787/mcp"}}}"#,
] {
assert!(parse_validated_settings(raw).is_ok(), "rejected {raw}");
}
let remote =
r#"{"mcp_servers":{"remote":{"type":"http","url":"http://mcp.example.test/mcp"}}}"#;
assert!(
parse_validated_settings(remote).is_err(),
"accepted {remote}"
);
let userinfo = r#"{"mcp_servers":{"remote":{"type":"http","url":"https://user:pass@mcp.example.test/mcp"}}}"#;
assert!(
parse_validated_settings(userinfo).is_err(),
"accepted {userinfo}"
);
}
#[test]
fn mcp_http_oauth_config_parses_validates_serializes_and_redacts_debug() {
let settings: Settings = serde_json::from_str(
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"X-Team":"platform"},"oauth":{"client_id":"public-client","scopes":["search","offline_access"],"authorization_server":"https://auth.example.test"}}}}"#,
)
.unwrap();
let config = settings.mcp_servers.get("remote").unwrap();
let McpServerConfig::Http(http) = config else {
panic!("expected http config");
};
let oauth = http.oauth.as_ref().unwrap();
assert_eq!(oauth.client_id.as_deref(), Some("public-client"));
assert_eq!(oauth.scopes, vec!["search", "offline_access"]);
assert_eq!(
oauth.authorization_server.as_deref(),
Some("https://auth.example.test")
);
let debug = format!("{oauth:?}");
assert!(debug.contains("[REDACTED]"), "{debug}");
assert!(!debug.contains("public-client"), "{debug}");
let serialized = serde_json::to_value(settings).unwrap();
assert_eq!(
serialized["mcp_servers"]["remote"]["oauth"]["client_id"],
"public-client"
);
}
#[test]
fn mcp_http_oauth_rejects_authorization_headers_and_invalid_fields() {
for raw in [
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"{env:MCP_TOKEN}"},"oauth":{}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Proxy-Authorization":"{env:MCP_TOKEN}"},"oauth":{}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"client_id":" "}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"scopes":[""]}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"scopes":["bad\u0007scope"]}}}}"#,
] {
assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
}
for raw in [
r#"{"mcp_servers":{"local":{"type":"http","url":"http://localhost:8787/mcp","oauth":{"authorization_server":"http://localhost:8788"}}}}"#,
r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.1:8787/mcp","oauth":{"authorization_server":"http://127.0.0.2:8788"}}}}"#,
] {
assert!(parse_validated_settings(raw).is_ok(), "rejected {raw}");
}
let remote = r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"authorization_server":"http://auth.example.test"}}}}"#;
assert!(
parse_validated_settings(remote).is_err(),
"accepted {remote}"
);
}
#[test]
fn settings_schema_generation_includes_mcp_http_config() {
let schema = schemars::schema_for!(Settings);
let schema_text = serde_json::to_string(&schema).unwrap();
assert!(schema_text.contains("McpHttpServerConfig"), "{schema_text}");
assert!(schema_text.contains("url"), "{schema_text}");
assert!(schema_text.contains("headers"), "{schema_text}");
assert!(schema_text.contains("McpOAuthConfig"), "{schema_text}");
assert!(schema_text.contains("client_id"), "{schema_text}");
assert!(schema_text.contains("scopes"), "{schema_text}");
assert!(
schema_text.contains("authorization_server"),
"{schema_text}"
);
}
#[test]
fn mcp_servers_reject_invalid_names_command_and_timeout() {
for raw in [
r#"{"mcp_servers":{"bad/name":{"type":"stdio","command":"node"}}}"#,
r#"{"mcp_servers":{"bad__name":{"type":"stdio","command":"node"}}}"#,
r#"{"mcp_servers":{"mock":{"type":"stdio","command":" "}}}"#,
r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","timeout":0}}}"#,
r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","timeout":301}}}"#,
] {
assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
}
}
#[test]
fn lsp_settings_parse_defaults_validate_and_serialize() {
let absent: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(absent.lsp, LspSettings::default());
assert!(!absent.lsp.enabled);
assert!(absent.lsp.inject_diagnostics_on_edit);
assert_eq!(
absent.lsp.diagnostics_wait_ms,
DEFAULT_LSP_DIAGNOSTICS_WAIT_MS
);
assert_eq!(
absent.lsp.idle_shutdown_minutes,
DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES
);
assert!(serde_json::to_value(&absent).unwrap().get("lsp").is_none());
let settings: Settings = serde_json::from_str(
r#"{"lsp":{"enabled":true,"inject_diagnostics_on_edit":false,"diagnostics_wait_ms":1500,"idle_shutdown_minutes":30,"servers":{"rust-analyzer":{"command":"rust-analyzer","args":["--log-file","ra.log"],"enabled":false}}}}"#,
)
.unwrap();
validate_settings(&settings).unwrap();
assert!(settings.lsp.enabled);
assert!(!settings.lsp.inject_diagnostics_on_edit);
assert_eq!(settings.lsp.diagnostics_wait_ms, 1500);
assert_eq!(settings.lsp.idle_shutdown_minutes, 30);
let rust_analyzer = settings.lsp.servers.get("rust-analyzer").unwrap();
assert_eq!(rust_analyzer.command, "rust-analyzer");
assert_eq!(rust_analyzer.args, vec!["--log-file", "ra.log"]);
assert!(!rust_analyzer.enabled);
let serialized = serde_json::to_value(settings).unwrap();
assert_eq!(serialized["lsp"]["enabled"], true);
assert_eq!(
serialized["lsp"]["servers"]["rust-analyzer"]["command"],
"rust-analyzer"
);
}
#[test]
fn lsp_settings_reject_invalid_values() {
for raw in [
r#"{"lsp":{"diagnostics_wait_ms":0}}"#,
r#"{"lsp":{"diagnostics_wait_ms":30001}}"#,
r#"{"lsp":{"idle_shutdown_minutes":0}}"#,
r#"{"lsp":{"idle_shutdown_minutes":241}}"#,
r#"{"lsp":{"servers":{"":{"command":"rust-analyzer"}}}}"#,
r#"{"lsp":{"servers":{"bad__name":{"command":"rust-analyzer"}}}}"#,
r#"{"lsp":{"servers":{"rust-analyzer":{"command":" "}}}}"#,
] {
assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
}
}
#[test]
fn lsp_settings_preserved_and_removed_by_settings_update() {
let mut raw = json!({
"lsp": {
"enabled": true,
"servers": {"rust-analyzer": {"command": "rust-analyzer"}}
},
"selected_primary_agent": "old"
});
let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
settings.selected_primary_agent = Some("new".to_string());
update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
assert_eq!(raw["lsp"]["enabled"], true);
assert_eq!(
raw["lsp"]["servers"]["rust-analyzer"]["command"],
"rust-analyzer"
);
assert_eq!(raw["selected_primary_agent"], "new");
settings.lsp = LspSettings::default();
update_raw_from_settings(&mut raw, &settings, SettingsScope::Global).unwrap();
assert!(raw.get("lsp").is_none());
}
#[test]
fn mcp_servers_preserved_by_settings_update() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","args":["server.js"],"env":{},"enabled":true,"timeout":30}},"future_setting":true}"#,
)
.unwrap();
set_selected_model(&paths, "provider", "model").unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["mcp_servers"]["mock"]["command"], "node");
assert_eq!(value["future_setting"], true);
}
#[test]
fn set_mcp_server_enabled_updates_stdio_http_and_preserves_unknowns() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{
"future_setting":{"keep":true},
"mcp_servers":{
"mock":{"type":"stdio","command":"node","enabled":true},
"remote":{"type":"http","url":"https://mcp.example.test/mcp","enabled":false}
}
}"#,
)
.unwrap();
set_mcp_server_enabled(&paths, "mock", false).unwrap();
set_mcp_server_enabled(&paths, "remote", true).unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["future_setting"]["keep"], true);
assert_eq!(value["mcp_servers"]["mock"]["enabled"], false);
assert_eq!(value["mcp_servers"]["remote"]["enabled"], true);
}
#[test]
fn set_mcp_server_enabled_rejects_missing_or_invalid_name() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, r#"{"mcp_servers":{}}"#).unwrap();
let missing = set_mcp_server_enabled(&paths, "missing", true)
.unwrap_err()
.to_string();
assert!(
missing.contains("mcp server not found: missing"),
"{missing}"
);
let invalid = set_mcp_server_enabled(&paths, "bad/name", true)
.unwrap_err()
.to_string();
assert!(invalid.contains("mcp server name"), "{invalid}");
}
#[test]
fn herdr_settings_default_enabled_and_unknown_fields() {
let absent: Settings = serde_json::from_str("{}").unwrap();
assert!(absent.integrations.herdr.is_default());
assert!(!absent.integrations.herdr.enabled);
let enabled: Settings =
serde_json::from_str(r#"{"integrations":{"herdr":{"enabled":true,"future":"kept"}}}"#)
.unwrap();
assert!(enabled.integrations.herdr.enabled);
let value = serde_json::to_value(&enabled).unwrap();
assert_eq!(value["integrations"]["herdr"]["enabled"], true);
assert_eq!(value["integrations"]["herdr"]["future"], "kept");
let default_value = serde_json::to_value(Settings::default()).unwrap();
assert!(default_value.get("integrations").is_none());
}
}