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()
}
}