use serde_json::Value;
use crate::config::{
CliConfigOverrides, EffectiveConfig, McPaths,
settings_storage::{
ensure_settings_schema_files as ensure_settings_schema_files_storage,
insert_default_schema_ref_if_absent, merge_local_settings_json,
merge_local_settings_values, read_merged_settings_json, read_settings_json_or_empty,
settings_path_for_scope, update_settings_json,
},
};
use super::{
core::{AppearanceSettings, SessionSettings, Settings, SettingsListKind, SettingsScope},
json::{remove_path, set_path, value_at_path, value_at_path_mut_or_create},
validation::validate_settings,
wire::{
SettingsDocument, canonical_settings_value, migrate_settings_value,
session_settings_from_normalized_value,
},
};
#[cfg(test)]
pub(crate) fn ensure_settings_schema_files(paths: &McPaths) -> anyhow::Result<()> {
ensure_settings_schema_files_and_load(paths).map(|_| ())
}
fn ensure_settings_schema_files_and_load(
paths: &McPaths,
) -> anyhow::Result<Option<(serde_json::Value, SettingsDocument, SessionSettings)>> {
let schema = serde_json::to_string_pretty(&schemars::schema_for!(Settings))?;
ensure_settings_schema_files_storage(paths, schema.as_bytes(), |raw| {
parse_settings_document_with_session_settings(raw.clone())
})
.map(|loaded| {
loaded.map(|(raw, (document, session_settings))| (raw, document, session_settings))
})
}
#[cfg(test)]
pub(crate) fn load_config_with_settings(
paths: McPaths,
cli: CliConfigOverrides,
) -> anyhow::Result<(
EffectiveConfig,
Settings,
AppearanceSettings,
SessionSettings,
)> {
let (document, session_settings) = read_settings_document_with_session_settings(&paths)?;
let config = EffectiveConfig::from_loaded_settings(paths, cli, document.settings.clone())?;
Ok((
config,
document.settings,
document.appearance,
session_settings,
))
}
pub(crate) fn load_startup_config_with_settings(
paths: McPaths,
cli: CliConfigOverrides,
) -> anyhow::Result<(
EffectiveConfig,
Settings,
AppearanceSettings,
SessionSettings,
)> {
let prepared = ensure_settings_schema_files_and_load(&paths)?;
let local_path = paths
.local_settings_file
.as_ref()
.filter(|path| path.exists())
.unwrap_or(&paths.project_settings_file);
let (document, session_settings) = match prepared {
Some((_global_raw, document, session_settings)) if !local_path.exists() => {
(document, session_settings)
}
Some((global_raw, _, _)) => {
let raw = merge_local_settings_json(&paths, global_raw)?;
let (document, session_settings) = parse_settings_document_with_session_settings(raw)?;
(document, session_settings)
}
None => {
let global_raw = read_settings_json_or_empty(&paths.settings_file)?;
let (document, session_settings) =
parse_settings_document_with_session_settings(global_raw.clone())?;
if local_path.exists() {
let raw = merge_local_settings_json(&paths, global_raw)?;
parse_settings_document_with_session_settings(raw)?
} else {
(document, session_settings)
}
}
};
let config = EffectiveConfig::from_loaded_settings(paths, cli, document.settings.clone())?;
Ok((
config,
document.settings,
document.appearance,
session_settings,
))
}
pub(crate) fn read_settings(paths: &McPaths) -> anyhow::Result<Settings> {
read_settings_document(paths).map(|document| document.settings)
}
pub(crate) fn read_settings_locked(paths: &McPaths) -> anyhow::Result<Settings> {
use crate::config::settings_storage::with_settings_file_lock;
let deadline = std::time::Instant::now() + crate::persistence::LOCK_WAIT_TIMEOUT;
with_settings_file_lock(&paths.settings_file, deadline, || {
if paths.settings_file == paths.project_settings_file {
read_settings(paths)
} else {
with_settings_file_lock(&paths.project_settings_file, deadline, || {
read_settings(paths)
})
}
})
}
#[cfg(test)]
pub(super) fn read_settings_with_session_settings(
paths: &McPaths,
) -> anyhow::Result<(Settings, SessionSettings)> {
read_settings_document_with_session_settings(paths)
.map(|(document, session_settings)| (document.settings, session_settings))
}
pub(crate) fn read_settings_document(paths: &McPaths) -> anyhow::Result<SettingsDocument> {
read_settings_document_with_session_settings(paths).map(|(document, _)| document)
}
pub(super) fn read_settings_document_with_session_settings(
paths: &McPaths,
) -> anyhow::Result<(SettingsDocument, SessionSettings)> {
let raw = read_merged_settings_json(paths)?;
let (document, session_settings) = parse_settings_document_with_session_settings(raw)?;
Ok((document, session_settings))
}
fn parse_settings_document_with_session_settings(
raw: serde_json::Value,
) -> anyhow::Result<(SettingsDocument, SessionSettings)> {
let normalized = migrate_settings_value(raw)?;
let session_settings = session_settings_from_normalized_value(&normalized)?;
let document = SettingsDocument::from_normalized_value(&normalized)?;
validate_settings(&document.settings)?;
Ok((document, session_settings))
}
#[cfg(test)]
pub(crate) fn write_settings(paths: &McPaths, settings: &Settings) -> anyhow::Result<()> {
validate_settings(settings)?;
update_settings_json(paths, SettingsScope::Global, |raw| {
let before: Settings = serde_json::from_value(raw.clone())?;
update_raw_from_settings(raw, &before, settings, SettingsScope::Global)
})?;
Ok(())
}
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<()> {
update_settings_for_scope_with_disabled_list(paths, scope, None, mutate)
}
pub(super) fn update_settings_for_scope_with_disabled_list(
paths: &McPaths,
scope: SettingsScope,
explicit_disabled_list: Option<SettingsListKind>,
mutate: impl FnOnce(&mut Settings),
) -> anyhow::Result<()> {
update_settings_json(paths, scope, |raw| {
let before: Settings = if scope == SettingsScope::Project {
let global_raw = read_settings_json_or_empty(&paths.settings_file)?;
let global_settings: Settings = serde_json::from_value(global_raw.clone())?;
validate_settings(&global_settings)?;
let effective_raw = merge_local_settings_values(global_raw, raw.clone())?;
serde_json::from_value(effective_raw)?
} else {
serde_json::from_value(raw.clone())?
};
if explicit_disabled_list.is_some() {
validate_settings(&before)?;
}
let mut after = before.clone();
mutate(&mut after);
validate_settings(&after)?;
update_raw_from_settings(raw, &before, &after, scope)?;
if scope == SettingsScope::Project
&& let Some(kind) = explicit_disabled_list
{
preserve_explicit_disabled_list(raw, &after, kind)?;
}
Ok(())
})?;
Ok(())
}
pub(crate) fn update_settings_checked<T>(
paths: &McPaths,
scope: SettingsScope,
explicit_paths: &[&[&str]],
mutate: impl FnOnce(&mut Settings, &Settings) -> anyhow::Result<()>,
project: impl FnOnce(&Settings) -> anyhow::Result<T>,
) -> anyhow::Result<T> {
let mut result = None;
update_settings_json(paths, scope, |raw| {
let effective_raw = match scope {
SettingsScope::Global => merge_local_settings_json(paths, raw.clone())?,
SettingsScope::Project => merge_local_settings_values(
read_settings_json_or_empty(&paths.settings_file)?,
raw.clone(),
)?,
};
let (effective, _) = parse_settings_document_with_session_settings(effective_raw)?;
let before = if scope == SettingsScope::Global {
serde_json::from_value(raw.clone())?
} else {
effective.settings.clone()
};
let mut after = before.clone();
mutate(&mut after, &effective.settings)?;
validate_settings(&after)?;
update_raw_from_settings(raw, &before, &after, scope)?;
let canonical = canonical_settings_value(&after)?;
for path in explicit_paths {
if scope == SettingsScope::Project
&& (path.starts_with(&["agent", "fast"])
|| path.starts_with(&["interface", "appearance"]))
{
anyhow::bail!("explicit setting requires global scope");
}
let value = value_at_path(&canonical, path)
.ok_or_else(|| anyhow::anyhow!("explicit settings field has no value"))?;
set_path(raw, path, value.clone())?;
}
let updated = match scope {
SettingsScope::Global => merge_local_settings_json(paths, raw.clone())?,
SettingsScope::Project => merge_local_settings_values(
read_settings_json_or_empty(&paths.settings_file)?,
raw.clone(),
)?,
};
let (document, _) = parse_settings_document_with_session_settings(updated)?;
result = Some(project(&document.settings)?);
Ok(())
})?;
result.ok_or_else(|| anyhow::anyhow!("settings update produced no result"))
}
pub(super) fn update_raw_from_settings(
raw: &mut serde_json::Value,
before: &Settings,
after: &Settings,
scope: SettingsScope,
) -> anyhow::Result<()> {
let normalized = migrate_settings_value(std::mem::take(raw))?;
let before_value = canonical_settings_value(before)?;
let after_value = canonical_settings_value(after)?;
let context_was_cleared = before.context.is_some() && after.context.is_none();
let primary_agent_was_cleared =
before.selected_primary_agent.is_some() && after.selected_primary_agent.is_none();
let mut updated = normalized;
apply_canonical_changes(&mut updated, &before_value, &after_value, &[])?;
if before.summarizer.auto_start != after.summarizer.auto_start {
set_path(
&mut updated,
&["agent", "summarizer", "auto_start"],
Value::Bool(after.summarizer.auto_start),
)?;
}
if scope == SettingsScope::Project {
for (field, cleared) in [
(
"provider",
before.summarizer.provider.is_some() && after.summarizer.provider.is_none(),
),
(
"model",
before.summarizer.model.is_some() && after.summarizer.model.is_none(),
),
(
"reasoning",
before.summarizer.reasoning.is_some() && after.summarizer.reasoning.is_none(),
),
(
"prompt",
before.summarizer.prompt.is_some() && after.summarizer.prompt.is_none(),
),
] {
if cleared {
set_path(&mut updated, &["agent", "summarizer", field], Value::Null)?;
}
}
}
if context_was_cleared {
set_path(&mut updated, &["agent", "context"], Value::Null)?;
}
if primary_agent_was_cleared {
set_path(&mut updated, &["agent", "primary_agent"], Value::Null)?;
}
if scope == SettingsScope::Project {
remove_path(&mut updated, &["agent", "fast"]);
remove_path(&mut updated, &["interface", "appearance"]);
} else {
insert_default_schema_ref_if_absent(&mut updated);
}
*raw = updated;
Ok(())
}
fn apply_canonical_changes(
raw: &mut Value,
before: &Value,
after: &Value,
path: &[&str],
) -> anyhow::Result<()> {
if before == after {
return Ok(());
}
match (before, after) {
(Value::Object(before), Value::Object(after)) => {
for (key, before_value) in before {
let child_path = append_path(path, key);
match after.get(key) {
Some(after_value) => {
apply_canonical_changes(raw, before_value, after_value, &child_path)?;
}
None if is_dynamic_map(path) || is_dynamic_map(&child_path) => {
remove_path(raw, &child_path)
}
None => remove_canonical_value(raw, before_value, &child_path),
}
}
for (key, after_value) in after {
if before.contains_key(key) {
continue;
}
let child_path = append_path(path, key);
add_canonical_value(raw, after_value, &child_path)?;
}
Ok(())
}
(_, after) => set_path(raw, path, after.clone()),
}
}
fn add_canonical_value(raw: &mut Value, value: &Value, path: &[&str]) -> anyhow::Result<()> {
match value {
Value::Object(object) => {
for (key, value) in object {
let child_path = append_path(path, key);
add_canonical_value(raw, value, &child_path)?;
}
Ok(())
}
value => set_path(raw, path, value.clone()),
}
}
fn remove_canonical_value(raw: &mut Value, value: &Value, path: &[&str]) {
if is_dynamic_map(path) {
remove_path(raw, path);
return;
}
match value {
Value::Object(object) => {
for (key, value) in object {
let child_path = append_path(path, key);
remove_canonical_value(raw, value, &child_path);
}
let is_empty = value_at_path(raw, path)
.and_then(Value::as_object)
.is_some_and(|object| object.is_empty());
if is_empty {
remove_path(raw, path);
}
}
_ => remove_path(raw, path),
}
}
fn append_path<'a>(path: &[&'a str], key: &'a str) -> Vec<&'a str> {
let mut child_path = Vec::with_capacity(path.len() + 1);
child_path.extend_from_slice(path);
child_path.push(key);
child_path
}
fn is_dynamic_map(path: &[&str]) -> bool {
matches!(
path,
["providers", "custom"]
| ["capabilities", "mcp"]
| ["capabilities", "lsp", "servers"]
| ["agent", "context", "model_overrides"]
)
}
#[cfg(test)]
pub(super) const KNOWN_SUBAGENTS_KEYS: &[&str] =
&["disabled", "schema_validation_max_retries", "execution"];
fn preserve_explicit_disabled_list(
raw: &mut Value,
settings: &Settings,
kind: SettingsListKind,
) -> anyhow::Result<()> {
let (group, list): (&[&str], &[String]) = match kind {
SettingsListKind::Skills => (&["knowledge", "skills"], &settings.skills.disabled),
SettingsListKind::Tools => (&["capabilities", "tools"], &settings.tools.disabled),
SettingsListKind::Subagents => (&["agent", "subagents"], &settings.subagents.disabled),
SettingsListKind::Models => (&["providers", "catalog"], &settings.models.disabled),
};
if list.is_empty() {
let group_value = value_at_path_mut_or_create(raw, group)?;
group_value.insert("disabled".to_string(), Value::Array(Vec::new()));
}
Ok(())
}