magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
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 the effective project settings so a partial local override is
        // not required to be valid without its global base.
        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(())
}