use std::collections::BTreeSet;
use crate::{
config::{
CustomProviderConfig, McPaths,
settings_storage::{
insert_default_schema_ref_if_absent, read_settings_json_or_empty,
settings_path_for_scope, update_settings_json,
},
validate_custom_provider_id,
},
primary_agents::validate_primary_agent_id,
thinking::ThinkingLevel,
};
use super::{
core::{AppearanceSettings, Settings, SettingsListKind, SettingsScope},
json::{has_path, set_path},
persistence::{
read_settings, read_settings_for_scope,
update_settings_for_scope_preserving_unknown_top_level_fields,
update_settings_for_scope_with_disabled_list,
update_settings_preserving_unknown_top_level_fields,
},
validation::{validate_mcp_server_name, validate_settings},
wire::migrate_settings_value,
};
fn validate_global_appearance(raw: &serde_json::Value) -> anyhow::Result<()> {
let Some(interface) = raw.get("interface") else {
return Ok(());
};
let interface = interface
.as_object()
.ok_or_else(|| anyhow::anyhow!("settings interface must be a JSON object"))?;
let Some(appearance) = interface.get("appearance") else {
return Ok(());
};
serde_json::from_value::<AppearanceSettings>(appearance.clone())
.map(|_| ())
.map_err(|error| anyhow::anyhow!("invalid settings interface.appearance: {error}"))
}
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) 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(test)]
pub(crate) fn disabled_skill_names(paths: &McPaths) -> anyhow::Result<BTreeSet<String>> {
Ok(disabled_skill_names_from_settings(&read_settings(paths)?))
}
#[cfg(test)]
pub(crate) fn selected_primary_agent(paths: &McPaths) -> anyhow::Result<Option<String>> {
Ok(read_settings(paths)?.selected_primary_agent)
}
pub(crate) fn fast_mode_enabled(paths: &McPaths) -> anyhow::Result<bool> {
Ok(read_settings_for_scope(paths, SettingsScope::Global)?
.fast
.enabled)
}
pub(crate) fn set_fast_mode(paths: &McPaths, enabled: bool) -> anyhow::Result<bool> {
update_settings_for_scope_preserving_unknown_top_level_fields(
paths,
SettingsScope::Global,
|settings| settings.fast.enabled = enabled,
)?;
Ok(enabled)
}
pub(crate) fn toggle_fast_mode(paths: &McPaths) -> anyhow::Result<bool> {
let mut enabled = false;
update_settings_for_scope_preserving_unknown_top_level_fields(
paths,
SettingsScope::Global,
|settings| {
enabled = !settings.fast.enabled;
settings.fast.enabled = enabled;
},
)?;
Ok(enabled)
}
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(validate_primary_agent_id).transpose()?;
update_settings_preserving_unknown_top_level_fields(paths, |settings| {
settings.selected_primary_agent = selected.clone();
})?;
Ok(selected)
}
#[cfg(test)]
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 mut effective = BTreeSet::new();
update_settings_for_scope_with_disabled_list(paths, scope, Some(kind), |settings| {
effective = disabled_names_from_settings(settings, kind);
if disabled {
effective.insert(name.to_string());
} else {
effective.remove(name);
}
let list = effective.iter().cloned().collect();
match kind {
SettingsListKind::Skills => settings.skills.disabled = list,
SettingsListKind::Tools => settings.tools.disabled = list,
SettingsListKind::Subagents => settings.subagents.disabled = list,
SettingsListKind::Models => settings.models.disabled = list,
}
})?;
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));
}
let settings = if scope == SettingsScope::Project {
read_settings(paths)?
} else {
read_settings_for_scope(paths, scope)?
};
Ok(disabled_names_from_settings(&settings, kind))
}
pub(crate) fn scope_has_disabled_list(
paths: &McPaths,
scope: SettingsScope,
kind: SettingsListKind,
) -> anyhow::Result<bool> {
let raw = migrate_settings_value(read_settings_json_or_empty(&settings_path_for_scope(
paths, scope,
))?)?;
Ok(match kind {
SettingsListKind::Skills => has_path(raw.as_object(), &["knowledge", "skills", "disabled"]),
SettingsListKind::Tools => {
has_path(raw.as_object(), &["capabilities", "tools", "disabled"])
}
SettingsListKind::Subagents => {
has_path(raw.as_object(), &["agent", "subagents", "disabled"])
}
SettingsListKind::Models => {
has_path(raw.as_object(), &["providers", "catalog", "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 set_appearance_theme(paths: &McPaths, theme_id: &str) -> anyhow::Result<()> {
if theme_id.is_empty() {
anyhow::bail!("theme ID must not be empty");
}
update_settings_json(paths, SettingsScope::Global, |raw| {
validate_global_appearance(raw)?;
set_path(
raw,
&["interface", "appearance", "theme"],
serde_json::Value::String(theme_id.to_string()),
)?;
validate_global_appearance(raw)?;
insert_default_schema_ref_if_absent(raw);
let settings: Settings = serde_json::from_value(raw.clone())?;
validate_settings(&settings)?;
Ok(())
})?;
Ok(())
}