magi-code 0.80.0

Repository-aware CLI coding agent for terminal work
Documentation
use serde_json::Value;

use crate::config::{
    McPaths,
    settings_storage::{
        insert_default_schema_ref_if_absent, merge_local_settings_json,
        merge_local_settings_values, read_settings_json_or_empty, update_settings_json,
    },
};

use super::{
    Settings, SettingsScope,
    json::set_path,
    persistence::{parse_settings_document_with_session_settings, read_settings},
    tools::{
        validate_model_identifier, validate_view_image_identifier,
        validate_view_image_max_image_bytes,
    },
};

/// Canonical leaf paths accepted by the settings modal. No credential or arbitrary JSON writes.
pub(crate) const EDITABLE_SETTINGS_PATHS: &[&str] = &[
    "providers.catalog.disabled",
    "agent.compaction.provider",
    "agent.compaction.model",
    "agent.compaction.auto.enabled",
    "agent.compaction.auto.threshold_percent",
    "agent.compaction.auto.threshold_tokens",
    "agent.compaction.auto.max_compactions_per_run",
    "agent.context.enabled",
    "agent.context.max_tokens",
    "agent.context.reserve_tokens",
    "agent.context.keep_recent_tokens",
    "agent.subagents.execution.absolute_paths",
    "agent.subagents.execution.max_depth",
    "agent.summarizer.auto_start",
    "agent.summarizer.model",
    "agent.summarizer.provider",
    "agent.summarizer.reasoning",
    "agent.side.provider",
    "agent.side.model",
    "automation.integrations.herdr.enabled",
    "capabilities.tools.output_compression.enabled",
    "capabilities.tools.view_image.absolute_paths",
    "capabilities.tools.view_image.max_image_bytes",
    "capabilities.tools.view_image.vision_model",
    "capabilities.tools.view_image.vision_model.provider",
    "capabilities.tools.view_image.vision_model.model",
    "interface.tui.subagent_card_rows",
    "interface.tui.autocomplete.respects_gitignore",
    "knowledge.skills.additional_paths",
    "providers.streaming.semantic_progress_timeout_seconds",
    "providers.streaming.subagent_semantic_progress_timeout_seconds",
    "sessions.titles.enabled",
    "sessions.titles.model",
    "sessions.titles.provider",
];

/// Global editing shows global values; project editing shows inherited effective values.
pub(crate) fn load_settings_editor(
    paths: &McPaths,
    scope: SettingsScope,
) -> anyhow::Result<Settings> {
    match scope {
        SettingsScope::Global => {
            parse_editor_settings(read_settings_json_or_empty(&paths.settings_file)?)
        }
        SettingsScope::Project => {
            parse_editor_settings(read_settings_json_or_empty(&paths.settings_file)?)?;
            let settings = read_settings(paths)?;
            validate_editor_settings(&settings)?;
            Ok(settings)
        }
    }
}

/// Apply only changed fields to the latest document under the existing storage locks.
/// Values use canonical settings JSON types; null clears optional selections. Send related
/// changes (such as a new image provider and model) together. Project values are explicitly
/// pinned even when equal to inherited values. Returns the validated effective settings.
/// The `vision_model` group accepts only null to clear the optional image selection;
/// configure it through its provider/model leaves to preserve unknown nested fields.
pub(crate) fn save_settings_editor(
    paths: &McPaths,
    scope: SettingsScope,
    changes: &[(&str, Value)],
) -> anyhow::Result<Settings> {
    for (path, value) in changes {
        anyhow::ensure!(
            EDITABLE_SETTINGS_PATHS.contains(path),
            "setting is not editable: {path}"
        );
        if *path == "capabilities.tools.view_image.vision_model" {
            anyhow::ensure!(
                value.is_null(),
                "set image provider/model fields together, or use null to clear the selection"
            );
        }
    }
    if changes.is_empty() {
        parse_editor_settings(read_settings_json_or_empty(&paths.settings_file)?)?;
        let settings = read_settings(paths)?;
        validate_editor_settings(&settings)?;
        return Ok(settings);
    }
    let mut saved = None;
    update_settings_json(paths, scope, |raw| {
        for (path, value) in changes {
            set_path(raw, &path.split('.').collect::<Vec<_>>(), value.clone())?;
        }
        // A project layer may be partial (for example inheriting an image provider).
        // Global values must also be valid without project overrides hiding bad input.
        if scope == SettingsScope::Global {
            parse_editor_settings(raw.clone())?;
        }
        let effective = match scope {
            SettingsScope::Global => {
                insert_default_schema_ref_if_absent(raw);
                merge_local_settings_json(paths, raw.clone())?
            }
            SettingsScope::Project => {
                let global = read_settings_json_or_empty(&paths.settings_file)?;
                parse_editor_settings(global.clone())?;
                merge_local_settings_values(global, raw.clone())?
            }
        };
        let settings = parse_editor_settings(effective)?;
        settings
            .session_titles
            .eligible_config()
            .map_err(anyhow::Error::msg)?;
        saved = Some(settings);
        Ok(())
    })?;
    saved.ok_or_else(|| anyhow::anyhow!("settings update produced no result"))
}

fn parse_editor_settings(raw: Value) -> anyhow::Result<Settings> {
    let (document, _) = parse_settings_document_with_session_settings(raw)?;
    validate_editor_settings(&document.settings)?;
    Ok(document.settings)
}

fn validate_editor_settings(settings: &Settings) -> anyhow::Result<()> {
    if settings.compaction.provider.is_some() || settings.compaction.model.is_some() {
        // Validate overrides with the runtime contract; inherited active selection is not edited here.
        settings
            .compaction
            .resolve_config("", "")
            .map_err(anyhow::Error::msg)?;
    }
    let image = &settings.tools.view_image;
    validate_view_image_max_image_bytes(image.max_image_bytes)?;
    if let Some(model) = &image.vision_model {
        validate_view_image_identifier("provider", &model.provider)?;
        validate_view_image_identifier("model", &model.model)?;
    }
    anyhow::ensure!(
        settings.tools.subagents.max_depth
            == super::tools::clamp_subagent_max_depth(settings.tools.subagents.max_depth),
        "agent.subagents.execution.max_depth must be between 1 and {}",
        crate::subagents::MAX_SUBAGENT_MAX_DEPTH
    );
    for (field, value) in [
        (
            "sessions.titles.provider",
            &settings.session_titles.provider,
        ),
        ("sessions.titles.model", &settings.session_titles.model),
    ] {
        if let Some(value) = value {
            validate_model_identifier(field, value)?;
        }
    }
    Ok(())
}