magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use regex::Regex;
use std::{collections::BTreeMap, net::IpAddr};

use super::{
    agent::{
        AutoCompactionSettings, CompactionSettings, MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES,
        MAX_TUI_SUBAGENT_CARD_ROWS, MIN_TUI_SUBAGENT_CARD_ROWS, SubagentsSettings, TuiSettings,
    },
    core::Settings,
    providers::{
        MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS, ProviderStreamSettings, TtsrSettings,
    },
    services::{
        LspSettings, MAX_LSP_DIAGNOSTICS_WAIT_MS, MAX_LSP_IDLE_SHUTDOWN_MINUTES,
        MAX_MCP_TIMEOUT_SECONDS, McpHttpServerConfig, McpOAuthConfig, McpServerConfig,
        McpServersSettings,
    },
};
use crate::config::custom_provider_config::validate_custom_provider_settings;

pub(crate) fn validate_settings(settings: &Settings) -> anyhow::Result<()> {
    validate_custom_provider_settings(settings)?;
    validate_context_model_overrides(settings.context.as_ref())?;
    validate_mcp_servers_settings(&settings.mcp_servers)?;
    validate_lsp_settings(&settings.lsp)?;
    settings.compaction.validate()?;
    settings.summarizer.validate()?;
    settings.provider_stream.validate()?;
    settings.subagents.validate()?;
    settings.tui.validate()?;
    settings.ttsr.validate()?;
    Ok(())
}

fn validate_context_model_overrides(
    context: Option<&crate::context::ContextBudget>,
) -> anyhow::Result<()> {
    let Some(context) = context else {
        return Ok(());
    };
    for (key, model_override) in &context.model_overrides {
        let Some((provider, model)) = key.split_once('/') else {
            anyhow::bail!("context.model_overrides key '{key}' must use provider/model");
        };
        if provider.is_empty() || model.is_empty() {
            anyhow::bail!("context.model_overrides key '{key}' must use non-empty provider/model");
        }
        if key
            .chars()
            .any(|ch| ch.is_ascii_whitespace() || ch.is_ascii_control())
        {
            anyhow::bail!(
                "context.model_overrides key '{key}' must not contain ASCII whitespace or control characters"
            );
        }
        if model_override.is_empty() {
            anyhow::bail!(
                "context.model_overrides.{key} must set at least one of max_tokens, reserve_tokens, or keep_recent_tokens"
            );
        }
    }
    Ok(())
}

pub(crate) fn validate_lsp_settings(settings: &LspSettings) -> anyhow::Result<()> {
    if !(1..=MAX_LSP_DIAGNOSTICS_WAIT_MS).contains(&settings.diagnostics_wait_ms) {
        anyhow::bail!(
            "lsp.diagnostics_wait_ms must be between 1 and {MAX_LSP_DIAGNOSTICS_WAIT_MS} milliseconds"
        );
    }
    if !(1..=MAX_LSP_IDLE_SHUTDOWN_MINUTES).contains(&settings.idle_shutdown_minutes) {
        anyhow::bail!(
            "lsp.idle_shutdown_minutes must be between 1 and {MAX_LSP_IDLE_SHUTDOWN_MINUTES} minutes"
        );
    }
    for (name, config) in &settings.servers {
        validate_lsp_server_name(name)?;
        if config.command.trim().is_empty() {
            anyhow::bail!("lsp.servers.{name}.command must not be empty");
        }
    }
    Ok(())
}

fn validate_lsp_server_name(name: &str) -> anyhow::Result<()> {
    if name.trim().is_empty() {
        anyhow::bail!("lsp server name must not be empty");
    }
    if name.contains("__") {
        anyhow::bail!("lsp server name '{name}' must not contain '__'");
    }
    Ok(())
}

pub(crate) fn validate_mcp_servers_settings(servers: &McpServersSettings) -> anyhow::Result<()> {
    for (name, config) in servers {
        validate_mcp_server_name(name)?;
        match config {
            McpServerConfig::Stdio(stdio) => {
                if stdio.command.trim().is_empty() {
                    anyhow::bail!("mcp_servers.{name}.command must not be empty");
                }
                validate_mcp_timeout(name, stdio.timeout)?;
            }
            McpServerConfig::Http(http) => validate_mcp_http_server(name, http)?,
        }
    }
    Ok(())
}

fn validate_mcp_timeout(name: &str, timeout: Option<u64>) -> anyhow::Result<()> {
    if let Some(timeout) = timeout
        && !(1..=MAX_MCP_TIMEOUT_SECONDS).contains(&timeout)
    {
        anyhow::bail!(
            "mcp_servers.{name}.timeout must be between 1 and {MAX_MCP_TIMEOUT_SECONDS} seconds"
        );
    }
    Ok(())
}

fn validate_mcp_http_server(name: &str, config: &McpHttpServerConfig) -> anyhow::Result<()> {
    validate_mcp_http_url(name, &config.url)?;
    validate_mcp_timeout(name, config.timeout)?;
    if let Some(oauth) = &config.oauth {
        validate_mcp_oauth_config(name, oauth, &config.headers)?;
    }
    for (header_name, header_value) in &config.headers {
        validate_mcp_http_header_name(name, header_name)?;
        let env_ref = crate::mcp::headers::parse_env_header_ref(header_value);
        if crate::mcp::headers::is_env_header_ref_syntax(header_value) && env_ref.is_none() {
            anyhow::bail!(
                "mcp_servers.{name}.headers.{header_name} must use {{env:VAR_NAME}} with a valid environment variable name"
            );
        }
        if crate::mcp::headers::is_sensitive_header(header_name) && env_ref.is_none() {
            anyhow::bail!(
                "mcp_servers.{name}.headers.{header_name} is sensitive and must use {{env:VAR_NAME}}"
            );
        }
    }
    Ok(())
}

fn validate_mcp_oauth_config(
    name: &str,
    oauth: &McpOAuthConfig,
    headers: &BTreeMap<String, String>,
) -> anyhow::Result<()> {
    for header_name in headers.keys() {
        if header_name.eq_ignore_ascii_case("authorization")
            || header_name.eq_ignore_ascii_case("proxy-authorization")
        {
            anyhow::bail!(
                "mcp_servers.{name}.headers.{header_name} must not be configured when mcp_servers.{name}.oauth is configured"
            );
        }
    }
    if let Some(client_id) = &oauth.client_id
        && client_id.trim().is_empty()
    {
        anyhow::bail!("mcp_servers.{name}.oauth.client_id must not be empty");
    }
    for scope in &oauth.scopes {
        if scope.trim().is_empty()
            || scope
                .bytes()
                .any(|byte| !byte.is_ascii() || byte.is_ascii_control())
        {
            anyhow::bail!(
                "mcp_servers.{name}.oauth.scopes entries must be non-empty printable ASCII"
            );
        }
    }
    if let Some(url) = &oauth.authorization_server {
        validate_mcp_http_url_field(name, "oauth.authorization_server", url)?;
    }
    Ok(())
}

fn validate_mcp_http_url(name: &str, url: &str) -> anyhow::Result<()> {
    validate_mcp_http_url_field(name, "url", url)
}

pub(crate) fn validate_mcp_http_url_field(
    name: &str,
    field: &str,
    url: &str,
) -> anyhow::Result<()> {
    let parsed = reqwest::Url::parse(url)
        .map_err(|_| anyhow::anyhow!("mcp_servers.{name}.{field} must be an absolute HTTP URL"))?;
    if !parsed.username().is_empty() || parsed.password().is_some() {
        anyhow::bail!("mcp_servers.{name}.{field} must not contain credentials");
    }
    match parsed.scheme() {
        "https" => Ok(()),
        "http" if is_loopback_http_host(parsed.host_str()) => Ok(()),
        "http" => anyhow::bail!(
            "mcp_servers.{name}.{field} must use https; http is allowed only for loopback hosts"
        ),
        _ => anyhow::bail!("mcp_servers.{name}.{field} must use http or https"),
    }
}

fn is_loopback_http_host(host: Option<&str>) -> bool {
    match host {
        Some("localhost") => true,
        Some(host) => host
            .trim_matches(['[', ']'])
            .parse::<IpAddr>()
            .is_ok_and(|ip| ip.is_loopback()),
        None => false,
    }
}

fn validate_mcp_http_header_name(server_name: &str, header_name: &str) -> anyhow::Result<()> {
    if header_name.is_empty() {
        anyhow::bail!("mcp_servers.{server_name}.headers contains an empty header name");
    }
    if header_name
        .bytes()
        .any(|byte| !byte.is_ascii() || byte.is_ascii_control() || byte == b':' || byte == b' ')
    {
        anyhow::bail!(
            "mcp_servers.{server_name}.headers.{header_name} must be visible ASCII without colon, spaces, or control characters"
        );
    }
    Ok(())
}

pub(crate) fn validate_mcp_server_name(name: &str) -> anyhow::Result<()> {
    if name.is_empty() {
        anyhow::bail!("mcp server name must not be empty");
    }
    if name.contains("__") {
        anyhow::bail!("mcp server name '{name}' must not contain '__'");
    }
    if name.ends_with('_') {
        anyhow::bail!(
            "mcp server name '{name}' must not end with '_' because mcp__<server>__<tool> would be ambiguous; choose a name that does not end with '_'"
        );
    }
    if !name
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
    {
        anyhow::bail!(
            "mcp server name '{name}' must contain only ASCII letters, digits, '_' or '-'"
        );
    }
    Ok(())
}

impl ProviderStreamSettings {
    fn validate(&self) -> anyhow::Result<()> {
        validate_provider_stream_timeout(
            "provider_stream.semantic_progress_timeout_seconds",
            self.semantic_progress_timeout_seconds,
        )?;
        validate_provider_stream_timeout(
            "provider_stream.subagent_semantic_progress_timeout_seconds",
            self.subagent_semantic_progress_timeout_seconds,
        )
    }
}

fn validate_provider_stream_timeout(field: &str, value: Option<u64>) -> anyhow::Result<()> {
    if let Some(value) = value
        && !(1..=MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS).contains(&value)
    {
        anyhow::bail!(
            "{field} must be between 1 and {MAX_PROVIDER_STREAM_SEMANTIC_PROGRESS_TIMEOUT_SECONDS} seconds"
        );
    }
    Ok(())
}

impl TtsrSettings {
    fn validate(&self) -> anyhow::Result<()> {
        if self.rules.len() > 128 {
            anyhow::bail!("ttsr.rules must contain at most 128 rules");
        }
        for (index, rule) in self.rules.iter().enumerate() {
            if rule.pattern.trim().is_empty() {
                anyhow::bail!("ttsr.rules[{index}].pattern must not be empty");
            }
            Regex::new(&rule.pattern).map_err(|_| {
                anyhow::anyhow!("ttsr.rules[{index}].pattern must be a valid regex")
            })?;
            if rule.reminder.trim().is_empty() {
                anyhow::bail!("ttsr.rules[{index}].reminder must not be empty");
            }
        }
        Ok(())
    }
}

impl SubagentsSettings {
    fn validate(&self) -> anyhow::Result<()> {
        if let Some(retries) = self.schema_validation_max_retries
            && retries > MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES
        {
            anyhow::bail!(
                "subagents.schema_validation_max_retries must be between 0 and {MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES}"
            );
        }
        Ok(())
    }
}

impl TuiSettings {
    fn validate(&self) -> anyhow::Result<()> {
        if let Some(rows) = self.subagent_card_rows
            && !(MIN_TUI_SUBAGENT_CARD_ROWS..=MAX_TUI_SUBAGENT_CARD_ROWS).contains(&rows)
        {
            anyhow::bail!(
                "tui.subagent_card_rows must be between {MIN_TUI_SUBAGENT_CARD_ROWS} and {MAX_TUI_SUBAGENT_CARD_ROWS}"
            );
        }
        Ok(())
    }
}

impl AutoCompactionSettings {
    fn validate(&self) -> anyhow::Result<()> {
        if self.enabled && self.threshold_percent.is_none() && self.threshold_tokens.is_none() {
            anyhow::bail!(
                "compaction.auto.enabled requires at least one of threshold_percent or threshold_tokens"
            );
        }
        if let Some(percent) = self.threshold_percent
            && !(1..=100).contains(&percent)
        {
            anyhow::bail!("compaction.auto.threshold_percent must be between 1 and 100");
        }
        if let Some(tokens) = self.threshold_tokens
            && tokens == 0
        {
            anyhow::bail!("compaction.auto.threshold_tokens must be greater than 0");
        }
        Ok(())
    }
}

impl CompactionSettings {
    fn validate(&self) -> anyhow::Result<()> {
        self.auto.validate()
    }
}