magi-code 0.82.1

Repository-aware CLI coding agent for terminal work
Documentation
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, session_settings_from_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 (mut 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 => {
            // Do not let a local override repair or hide an invalid global file.
            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)
            }
        }
    };
    super::mcp::load_mcp_servers(&paths, &mut document.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)
}

#[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 (mut document, session_settings) = parse_settings_document_with_session_settings(raw)?;
    super::mcp::load_mcp_servers(paths, &mut document.settings)?;
    Ok((document, session_settings))
}

pub(super) fn parse_settings_document_with_session_settings(
    raw: serde_json::Value,
) -> anyhow::Result<(SettingsDocument, SessionSettings)> {
    let session_settings = session_settings_from_value(&raw)?;
    let document = SettingsDocument::from_value(&raw)?;
    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(super) fn update_raw_from_settings(
    raw: &mut serde_json::Value,
    before: &Settings,
    after: &Settings,
    scope: SettingsScope,
) -> anyhow::Result<()> {
    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 = raw.clone();
    apply_canonical_changes(&mut updated, &before_value, &after_value, &[])?;
    if before.summarizer.auto_start != after.summarizer.auto_start {
        // Pin false too, including when disabling returns the whole group to defaults.
        set_path(
            &mut updated,
            &["agent", "summarizer", "auto_start"],
            Value::Bool(after.summarizer.auto_start),
        )?;
    }
    if scope == SettingsScope::Project {
        if !before.side.is_default() && after.side.is_default() {
            for field in ["provider", "model"] {
                set_path(&mut updated, &["agent", "side", field], Value::Null)?;
            }
        }
        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 {
                // Null clears a global override; omission would inherit it again.
                set_path(&mut updated, &["agent", "summarizer", field], Value::Null)?;
            }
        }
    }
    if context_was_cleared {
        // Keep an explicit null when a configured context is cleared. This is
        // distinct from an omitted context and preserves the existing wire
        // contract for settings mutations.
        set_path(&mut updated, &["agent", "context"], Value::Null)?;
    }
    if primary_agent_was_cleared {
        // Keep an explicit null when a configured primary agent is cleared.
        // This distinguishes clearing it from leaving it unset.
        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"]);
        remove_path(&mut updated, &["capabilities", "mcp_approvals"]);
    } 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_approvals"]
            | ["capabilities", "mcp_approvals", _]
            | ["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(())
}