use crate::Error; use crate::ProviderConfig; use crate::Result; use crate::mode::ModeConfig; use crate::tools::WEB_SEARCH_TOOL_NAME; use crate::ui::RoleLabelDisplay; use serde::de::{self, Deserializer, Visitor}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; /// Default location for the OWLEN configuration file pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml"; /// Current schema version written to `config.toml`. pub const CONFIG_SCHEMA_VERSION: &str = "1.8.0"; /// Provider config key for forcing Ollama provider mode. pub const OLLAMA_MODE_KEY: &str = "ollama_mode"; /// Extra config key storing the preferred Ollama Cloud endpoint. pub const OLLAMA_CLOUD_ENDPOINT_KEY: &str = "cloud_endpoint"; /// Canonical Ollama Cloud base URL. pub const OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com"; /// Legacy Ollama Cloud base URL (accepted for backward compatibility). pub const LEGACY_OLLAMA_CLOUD_BASE_URL: &str = "https://api.ollama.com"; /// Preferred environment variable used for Ollama Cloud authentication. pub const OLLAMA_API_KEY_ENV: &str = "OLLAMA_API_KEY"; /// Legacy environment variable accepted for backward compatibility. pub const LEGACY_OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY"; /// Legacy environment variable used by earlier Owlen releases. pub const LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV: &str = "OWLEN_OLLAMA_CLOUD_API_KEY"; /// Default hourly soft quota for Ollama Cloud usage visualization (tokens). pub const DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA: u64 = 50_000; /// Default weekly soft quota for Ollama Cloud usage visualization (tokens). pub const DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA: u64 = 250_000; /// Default TTL (seconds) for cached model listings per provider. pub const DEFAULT_PROVIDER_LIST_TTL_SECS: u64 = 60; /// Default context window (tokens) assumed when provider metadata is absent. pub const DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS: u32 = 8_192; /// Default base URL for local Ollama daemons. pub const OLLAMA_LOCAL_BASE_URL: &str = "http://localhost:11434"; /// Default OpenAI API base URL. pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; /// Environment variable name used for OpenAI API keys. pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY"; /// Default Anthropic API base URL. pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1"; /// Environment variable name used for Anthropic API keys. pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY"; /// Core configuration shared by all OWLEN clients #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { /// Schema version for on-disk configuration files #[serde(default = "Config::default_schema_version")] pub schema_version: String, /// General application settings pub general: GeneralSettings, /// MCP (Multi-Client-Provider) settings #[serde(default)] pub mcp: McpSettings, /// Provider specific configuration keyed by provider name #[serde(default)] pub providers: HashMap, /// UI preferences that frontends can opt into #[serde(default)] pub ui: UiSettings, /// Storage related options #[serde(default)] pub storage: StorageSettings, /// Input handling preferences #[serde(default)] pub input: InputSettings, /// Privacy controls for tooling and network usage #[serde(default)] pub privacy: PrivacySettings, /// Security controls for sandboxing and resource limits #[serde(default)] pub security: SecuritySettings, /// Per-tool configuration toggles #[serde(default)] pub tools: ToolSettings, /// Mode-specific tool availability configuration #[serde(default)] pub modes: ModeConfig, /// External MCP server definitions #[serde(default)] pub mcp_servers: Vec, /// User-scoped resource definitions #[serde(default)] pub mcp_resources: Vec, /// Resolved MCP servers across scopes (runtime only). #[serde(skip)] pub scoped_mcp_servers: Vec, /// Effective MCP servers after applying precedence rules (runtime only). #[serde(skip)] pub effective_mcp_servers: Vec, /// Resolved MCP resources across scopes (runtime only). #[serde(skip)] pub scoped_mcp_resources: Vec, /// Effective MCP resources after precedence (runtime only). #[serde(skip)] pub effective_mcp_resources: Vec, } impl Default for Config { fn default() -> Self { let providers = default_provider_configs(); Self { schema_version: Self::default_schema_version(), general: GeneralSettings::default(), mcp: McpSettings::default(), providers, ui: UiSettings::default(), storage: StorageSettings::default(), input: InputSettings::default(), privacy: PrivacySettings::default(), security: SecuritySettings::default(), tools: ToolSettings::default(), modes: ModeConfig::default(), mcp_servers: Vec::new(), mcp_resources: Vec::new(), scoped_mcp_servers: Vec::new(), effective_mcp_servers: Vec::new(), scoped_mcp_resources: Vec::new(), effective_mcp_resources: Vec::new(), } } } /// Configuration for an external MCP server process. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct McpServerConfig { /// Logical name used to reference the server (e.g., "web_search"). pub name: String, /// Command to execute (binary or script). pub command: String, /// Arguments passed to the command. #[serde(default)] pub args: Vec, /// Transport mechanism, currently only "stdio" is supported. #[serde(default = "McpServerConfig::default_transport")] pub transport: String, /// Optional environment variable map for the process. #[serde(default)] pub env: std::collections::HashMap, /// Optional OAuth configuration for remote servers. #[serde(default)] pub oauth: Option, } impl McpServerConfig { fn default_transport() -> String { "stdio".to_string() } } /// OAuth configuration for MCP servers that require delegated authentication. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct McpOAuthConfig { /// Public client identifier registered with the authorization server. pub client_id: String, /// Optional client secret for confidential clients. #[serde(default)] pub client_secret: Option, /// OAuth authorization endpoint (used for web-based flows). pub authorize_url: String, /// OAuth token endpoint. pub token_url: String, /// Optional device authorization endpoint for device-code flows. #[serde(default)] pub device_authorization_url: Option, /// Optional redirect URL (PKCE / authorization-code flows). #[serde(default)] pub redirect_url: Option, /// Requested OAuth scopes. #[serde(default)] pub scopes: Vec, /// Environment variable name populated with the bearer access token when spawning stdio servers. #[serde(default)] pub token_env: Option, /// Optional HTTP header name for bearer authentication (defaults to "Authorization"). #[serde(default)] pub header: Option, /// Optional prefix prepended to the access token (defaults to "Bearer "). #[serde(default)] pub header_prefix: Option, } impl McpOAuthConfig { pub fn header_name(&self) -> &str { self.header.as_deref().unwrap_or("Authorization") } pub fn header_prefix(&self) -> &str { self.header_prefix.as_deref().unwrap_or("Bearer ") } } /// Scope for MCP server configuration entries. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum McpConfigScope { /// User-level configuration stored under the user's config directory. User, /// Project configuration stored in the repository (e.g. `.mcp.json`). Project, /// Local overrides stored alongside the project but excluded from version control. Local, } impl McpConfigScope { fn precedence_iter() -> impl Iterator { [ McpConfigScope::Local, McpConfigScope::Project, McpConfigScope::User, ] .into_iter() } fn as_str(self) -> &'static str { match self { McpConfigScope::User => "user", McpConfigScope::Project => "project", McpConfigScope::Local => "local", } } } impl fmt::Display for McpConfigScope { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } impl FromStr for McpConfigScope { type Err = String; fn from_str(s: &str) -> std::result::Result { match s.to_ascii_lowercase().as_str() { "user" => Ok(McpConfigScope::User), "project" => Ok(McpConfigScope::Project), "local" => Ok(McpConfigScope::Local), other => Err(format!("Unknown MCP scope '{other}'")), } } } /// A resolved MCP server entry annotated with its configuration scope. #[derive(Debug, Clone)] pub struct ScopedMcpServer { pub scope: McpConfigScope, pub config: McpServerConfig, } /// Configuration for a predefined MCP resource reference. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct McpResourceConfig { /// Named MCP server that owns this resource. pub server: String, /// URI or path identifying the resource within the server. pub uri: String, /// Optional short title displayed in UI. #[serde(default)] pub title: Option, /// Optional detailed description shown in tooltips. #[serde(default)] pub description: Option, } /// Resource entry annotated with its originating scope. #[derive(Debug, Clone)] pub struct ScopedMcpResource { pub scope: McpConfigScope, pub config: McpResourceConfig, } impl Config { fn default_schema_version() -> String { CONFIG_SCHEMA_VERSION.to_string() } /// Load configuration from disk, falling back to defaults when missing pub fn load(path: Option<&Path>) -> Result { let path = match path { Some(path) => path.to_path_buf(), None => default_config_path(), }; if path.exists() { let content = fs::read_to_string(&path)?; let parsed: toml::Value = toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))?; let mut parsed = parsed; migrate_legacy_provider_tables(&mut parsed); let previous_version = parsed .get("schema_version") .and_then(|value| value.as_str()) .unwrap_or("0.0.0") .to_string(); if let Some(agent_table) = parsed.get("agent").and_then(|value| value.as_table()) && agent_table.contains_key("max_tool_calls") { log::warn!( "Configuration option agent.max_tool_calls is deprecated and ignored. \ The agent now uses agent.max_iterations." ); } let mut config: Config = parsed .try_into() .map_err(|e: toml::de::Error| crate::Error::Config(e.to_string()))?; config.ensure_defaults(); config.mcp.apply_backward_compat(); config.apply_schema_migrations(&previous_version); config.expand_provider_env_vars()?; config.refresh_mcp_servers(None)?; config.validate()?; Ok(config) } else { let mut config = Config::default(); config.expand_provider_env_vars()?; config.refresh_mcp_servers(None)?; Ok(config) } } /// Generate MCP server configurations for a given reference preset. pub fn preset_servers(tier: crate::mcp::presets::PresetTier) -> Vec { crate::mcp::presets::connectors_for_tier(tier) .into_iter() .map(|connector| connector.to_config()) .collect() } /// Persist configuration to disk pub fn save(&self, path: Option<&Path>) -> Result<()> { let mut validator = self.clone(); validator.refresh_mcp_servers(None)?; validator.validate()?; let path = match path { Some(path) => path.to_path_buf(), None => default_config_path(), }; if let Some(dir) = path.parent() { fs::create_dir_all(dir)?; } let mut snapshot = self.clone(); snapshot.schema_version = Config::default_schema_version(); let content = toml::to_string_pretty(&snapshot).map_err(|e| crate::Error::Config(e.to_string()))?; fs::write(path, content)?; Ok(()) } /// Get provider configuration by provider name pub fn provider(&self, name: &str) -> Option<&ProviderConfig> { let key = normalize_provider_key(name); self.providers.get(&key) } /// Update or insert a provider configuration pub fn upsert_provider(&mut self, name: impl Into, config: ProviderConfig) { let raw = name.into(); let key = normalize_provider_key(&raw); let mut config = config; if config.provider_type.is_empty() { config.provider_type = key.clone(); } self.providers.insert(key, config); } /// Resolve default model in order of priority: explicit default, first cached model, provider fallback pub fn resolve_default_model<'a>( &'a self, models: &'a [crate::types::ModelInfo], ) -> Option<&'a str> { if let Some(model) = self.general.default_model.as_deref() && models.iter().any(|m| m.id == model || m.name == model) { return Some(model); } if let Some(first) = models.first() { return Some(&first.id); } self.general.default_model.as_deref() } fn ensure_defaults(&mut self) { if self.general.default_provider.is_empty() || self.general.default_provider == "ollama" { self.general.default_provider = "ollama_local".to_string(); } let mut defaults = default_provider_configs(); for (name, default_cfg) in defaults.drain() { self.providers.entry(name).or_insert(default_cfg); } if let Some(local) = self.providers.get_mut("ollama_local") { normalize_local_provider_config(local); } if let Some(cloud) = self.providers.get_mut("ollama_cloud") { normalize_cloud_provider_config(cloud); } if self.schema_version.is_empty() { self.schema_version = Self::default_schema_version(); } } fn expand_provider_env_vars(&mut self) -> Result<()> { for (provider_name, provider) in self.providers.iter_mut() { expand_provider_entry(provider_name, provider)?; } Ok(()) } /// Refresh the resolved MCP server list by loading scope-specific definitions. pub fn refresh_mcp_servers(&mut self, project_hint: Option<&Path>) -> Result<()> { let mut scoped_servers = Vec::new(); let mut scoped_resources = Vec::new(); let mut user_servers = self.mcp_servers.clone(); expand_mcp_servers(&mut user_servers, "config.mcp_servers")?; for server in user_servers { scoped_servers.push(ScopedMcpServer { scope: McpConfigScope::User, config: server, }); } let mut user_resources = self.mcp_resources.clone(); expand_mcp_resources(&mut user_resources, "config.mcp_resources")?; for resource in user_resources { scoped_resources.push(ScopedMcpResource { scope: McpConfigScope::User, config: resource, }); } for scope in [McpConfigScope::Project, McpConfigScope::Local] { if let Some(path) = mcp_scope_path(scope, project_hint) { let mut file = read_scope_config(&path)?; let server_context = format!("mcp.{scope}.servers"); expand_mcp_servers(&mut file.servers, &server_context)?; for server in file.servers { scoped_servers.push(ScopedMcpServer { scope, config: server, }); } let resource_context = format!("mcp.{scope}.resources"); expand_mcp_resources(&mut file.resources, &resource_context)?; for resource in file.resources { scoped_resources.push(ScopedMcpResource { scope, config: resource, }); } } } let mut effective_servers = Vec::new(); let mut seen_servers = HashSet::new(); for scope in McpConfigScope::precedence_iter() { for entry in scoped_servers.iter().filter(|entry| entry.scope == scope) { if seen_servers.insert(entry.config.name.clone()) { effective_servers.push(entry.config.clone()); } } } let mut effective_resources = Vec::new(); let mut seen_resources: HashSet<(String, String)> = HashSet::new(); for scope in McpConfigScope::precedence_iter() { for entry in scoped_resources.iter().filter(|entry| entry.scope == scope) { let key = (entry.config.server.clone(), entry.config.uri.clone()); if seen_resources.insert(key) { effective_resources.push(entry.config.clone()); } } } self.scoped_mcp_servers = scoped_servers; self.effective_mcp_servers = effective_servers; self.scoped_mcp_resources = scoped_resources; self.effective_mcp_resources = effective_resources; Ok(()) } /// Return the merged MCP servers using scope precedence (local > project > user). pub fn effective_mcp_servers(&self) -> &[McpServerConfig] { &self.effective_mcp_servers } /// Return MCP servers annotated with their originating scope. pub fn scoped_mcp_servers(&self) -> &[ScopedMcpServer] { &self.scoped_mcp_servers } /// Return merged MCP resources using scope precedence (local > project > user). pub fn effective_mcp_resources(&self) -> &[McpResourceConfig] { &self.effective_mcp_resources } /// Return scoped MCP resources with their origin scope metadata. pub fn scoped_mcp_resources(&self) -> &[ScopedMcpResource] { &self.scoped_mcp_resources } /// Locate a configured resource by server and URI. pub fn find_resource(&self, server: &str, uri: &str) -> Option<&McpResourceConfig> { self.effective_mcp_resources .iter() .find(|resource| resource.server == server && resource.uri == uri) } /// Add or replace an MCP server definition within the specified scope. pub fn add_mcp_server( &mut self, scope: McpConfigScope, server: McpServerConfig, project_hint: Option<&Path>, ) -> Result<()> { match scope { McpConfigScope::User => { self.mcp_servers .retain(|existing| existing.name != server.name); self.mcp_servers.push(server); } other => { let path = mcp_scope_path(other, project_hint).ok_or_else(|| { Error::Config(format!( "Unable to resolve project root for MCP scope '{}'", other )) })?; let mut file = read_scope_config(&path)?; file.servers.retain(|existing| existing.name != server.name); file.servers.push(server); write_scope_config(&path, &file)?; } } self.refresh_mcp_servers(project_hint)?; Ok(()) } /// Remove an MCP server from the given scope, or infer the scope if omitted. pub fn remove_mcp_server( &mut self, scope: Option, name: &str, project_hint: Option<&Path>, ) -> Result> { let target_scope = if let Some(scope) = scope { scope } else { self.refresh_mcp_servers(project_hint)?; match self .scoped_mcp_servers .iter() .find(|entry| entry.config.name == name) { Some(entry) => entry.scope, None => return Ok(None), } }; let removed = match target_scope { McpConfigScope::User => { let before = self.mcp_servers.len(); self.mcp_servers.retain(|entry| entry.name != name); before != self.mcp_servers.len() } other => { let path = mcp_scope_path(other, project_hint).ok_or_else(|| { Error::Config(format!( "Unable to resolve project root for MCP scope '{}'", other )) })?; let mut file = read_scope_config(&path)?; let before = file.servers.len(); file.servers.retain(|entry| entry.name != name); if before == file.servers.len() { false } else { write_scope_config(&path, &file)?; true } } }; if removed { self.refresh_mcp_servers(project_hint)?; Ok(Some(target_scope)) } else { Ok(None) } } /// Validate configuration invariants and surface actionable error messages. pub fn validate(&self) -> Result<()> { self.validate_default_provider()?; self.validate_mcp_settings()?; self.validate_mcp_servers()?; self.validate_providers()?; Ok(()) } fn apply_schema_migrations(&mut self, previous_version: &str) { if previous_version != CONFIG_SCHEMA_VERSION { log::info!( "Upgrading configuration schema from '{}' to '{}'", previous_version, CONFIG_SCHEMA_VERSION ); } self.migrate_provider_entries(); if self.general.default_provider == "ollama" { self.general.default_provider = "ollama_local".to_string(); } self.ensure_defaults(); self.schema_version = CONFIG_SCHEMA_VERSION.to_string(); } fn migrate_provider_entries(&mut self) { let mut migrated = default_provider_configs(); let legacy_entries = std::mem::take(&mut self.providers); for (original_key, mut legacy) in legacy_entries { if original_key == "ollama" { Self::merge_legacy_ollama_provider(legacy, &mut migrated); continue; } let normalized = normalize_provider_key(&original_key); let entry = migrated .entry(normalized.clone()) .or_insert_with(|| ProviderConfig { enabled: true, provider_type: normalized.clone(), base_url: None, api_key: None, api_key_env: None, extra: HashMap::new(), }); if legacy.provider_type.is_empty() { legacy.provider_type = normalized.clone(); } entry.merge_from(legacy); if entry.provider_type.is_empty() { entry.provider_type = normalized; } } self.providers = migrated; // If the legacy local provider was configured with the hosted base URL, promote the // settings to the cloud provider for backward compatibility. let enable_cloud_from_local = self .providers .get("ollama_local") .and_then(|cfg| cfg.base_url.as_ref()) .map(|base| is_cloud_base_url(Some(base))) .unwrap_or(false); if enable_cloud_from_local { let mut local_api_key = None; let mut local_api_key_env = None; if let Some(local) = self.providers.get_mut("ollama_local") { local_api_key = local.api_key.take(); local_api_key_env = local.api_key_env.take(); local.enabled = false; } if let Some(cloud) = self.providers.get_mut("ollama_cloud") { if cloud.api_key.is_none() { cloud.api_key = local_api_key; } if cloud.api_key_env.is_none() { cloud.api_key_env = local_api_key_env; } if cloud.base_url.is_none() { cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); } let update_api_key_env = match cloud.api_key_env.as_deref() { None => true, Some(value) => { value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) } }; if update_api_key_env { cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); } cloud.enabled = true; } } } fn merge_legacy_ollama_provider( mut legacy: ProviderConfig, targets: &mut HashMap, ) { let mode = legacy .extra .remove(OLLAMA_MODE_KEY) .and_then(|value| value.as_str().map(|s| s.trim().to_ascii_lowercase())); let api_key_present = legacy .api_key .as_ref() .map(|value| !value.trim().is_empty()) .unwrap_or(false); let cloud_candidate = matches!(mode.as_deref(), Some("cloud")) || is_cloud_base_url(legacy.base_url.as_ref()); let should_enable_cloud = cloud_candidate || api_key_present; if matches!(mode.as_deref(), Some("local")) || !should_enable_cloud { if let Some(local) = targets.get_mut("ollama_local") { let mut copy = legacy.clone(); copy.api_key = None; copy.api_key_env = None; copy.enabled = true; local.merge_from(copy); local.enabled = true; if local.base_url.is_none() { local.base_url = Some(OLLAMA_LOCAL_BASE_URL.to_string()); } } } if should_enable_cloud || matches!(mode.as_deref(), Some("cloud")) { if let Some(cloud) = targets.get_mut("ollama_cloud") { legacy.enabled = true; cloud.merge_from(legacy); cloud.enabled = true; if cloud.base_url.is_none() { cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); } let update_api_key_env = match cloud.api_key_env.as_deref() { None => true, Some(value) => { value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) } }; if update_api_key_env { cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); } } } } fn validate_default_provider(&self) -> Result<()> { if self.general.default_provider.trim().is_empty() { return Err(crate::Error::Config( "general.default_provider must reference a configured provider".to_string(), )); } if self.provider(&self.general.default_provider).is_none() { return Err(crate::Error::Config(format!( "Default provider '{}' is not defined under [providers]", self.general.default_provider ))); } Ok(()) } fn validate_mcp_settings(&self) -> Result<()> { let has_effective_servers = if self.effective_mcp_servers.is_empty() { !self.mcp_servers.is_empty() } else { !self.effective_mcp_servers.is_empty() }; match self.mcp.mode { McpMode::RemoteOnly => { if !has_effective_servers { return Err(crate::Error::Config( "[mcp].mode = 'remote_only' requires at least one [[mcp_servers]] entry" .to_string(), )); } } McpMode::RemotePreferred => { if !self.mcp.allow_fallback && !has_effective_servers { return Err(crate::Error::Config( "[mcp].allow_fallback = false requires at least one [[mcp_servers]] entry" .to_string(), )); } } McpMode::Disabled => { return Err(crate::Error::Config( "[mcp].mode = 'disabled' is not supported by this build of Owlen".to_string(), )); } _ => {} } Ok(()) } fn validate_mcp_servers(&self) -> Result<()> { if self.scoped_mcp_servers.is_empty() { for server in &self.mcp_servers { validate_mcp_server_entry(server, McpConfigScope::User)?; } } else { for entry in &self.scoped_mcp_servers { validate_mcp_server_entry(&entry.config, entry.scope)?; } } Ok(()) } fn validate_providers(&self) -> Result<()> { for (name, provider) in &self.providers { if !provider.enabled { continue; } match name.as_str() { "ollama_local" => { if is_blank(&provider.base_url) { return Err(Error::Config( "providers.ollama_local.base_url must be set when enabled".into(), )); } } "ollama_cloud" => { if is_blank(&provider.base_url) { return Err(Error::Config( "providers.ollama_cloud.base_url must be set when enabled".into(), )); } if is_blank(&provider.api_key) && is_blank(&provider.api_key_env) { return Err(Error::Config( "providers.ollama_cloud requires `api_key` or `api_key_env` when enabled" .into(), )); } } "openai" | "anthropic" => { if is_blank(&provider.api_key) && is_blank(&provider.api_key_env) { return Err(Error::Config(format!( "providers.{name} requires `api_key` or `api_key_env` when enabled" ))); } } _ => {} } } Ok(()) } } fn default_provider_configs() -> HashMap { let mut providers = HashMap::new(); for name in ["ollama_local", "ollama_cloud", "openai", "anthropic"] { if let Some(config) = default_provider_config_for(name) { providers.insert(name.to_string(), config); } } providers } fn default_ollama_local_config() -> ProviderConfig { let mut extra = HashMap::new(); extra.insert( "list_ttl_secs".to_string(), serde_json::Value::Number(serde_json::Number::from(DEFAULT_PROVIDER_LIST_TTL_SECS)), ); extra.insert( "default_context_window".to_string(), serde_json::Value::Number(serde_json::Number::from(u64::from( DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS, ))), ); ProviderConfig { enabled: true, provider_type: canonical_provider_type("ollama_local"), base_url: Some(OLLAMA_LOCAL_BASE_URL.to_string()), api_key: None, api_key_env: None, extra, } } fn default_ollama_cloud_config() -> ProviderConfig { let mut extra = HashMap::new(); extra.insert( OLLAMA_CLOUD_ENDPOINT_KEY.to_string(), serde_json::Value::String(OLLAMA_CLOUD_BASE_URL.to_string()), ); extra.insert( "hourly_quota_tokens".to_string(), serde_json::Value::Number(serde_json::Number::from(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA)), ); extra.insert( "weekly_quota_tokens".to_string(), serde_json::Value::Number(serde_json::Number::from(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA)), ); extra.insert( "list_ttl_secs".to_string(), serde_json::Value::Number(serde_json::Number::from(DEFAULT_PROVIDER_LIST_TTL_SECS)), ); extra.insert( "default_context_window".to_string(), serde_json::Value::Number(serde_json::Number::from(u64::from( DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS, ))), ); ProviderConfig { enabled: false, provider_type: canonical_provider_type("ollama_cloud"), base_url: Some(OLLAMA_CLOUD_BASE_URL.to_string()), api_key: None, api_key_env: Some(OLLAMA_API_KEY_ENV.to_string()), extra, } } fn default_openai_config() -> ProviderConfig { ProviderConfig { enabled: false, provider_type: canonical_provider_type("openai"), base_url: Some(OPENAI_DEFAULT_BASE_URL.to_string()), api_key: None, api_key_env: Some(OPENAI_API_KEY_ENV.to_string()), extra: HashMap::new(), } } fn default_anthropic_config() -> ProviderConfig { ProviderConfig { enabled: false, provider_type: canonical_provider_type("anthropic"), base_url: Some(ANTHROPIC_DEFAULT_BASE_URL.to_string()), api_key: None, api_key_env: Some(ANTHROPIC_API_KEY_ENV.to_string()), extra: HashMap::new(), } } fn default_provider_config_for(name: &str) -> Option { match name { "ollama_local" => Some(default_ollama_local_config()), "ollama_cloud" => Some(default_ollama_cloud_config()), "openai" => Some(default_openai_config()), "anthropic" => Some(default_anthropic_config()), _ => None, } } fn ensure_numeric_extra( extra: &mut HashMap, key: &str, default_value: u64, ) { let needs_update = match extra.get(key) { Some(existing) => existing.as_u64().is_none(), None => true, }; if needs_update { extra.insert( key.to_string(), serde_json::Value::Number(serde_json::Number::from(default_value)), ); } } fn ensure_string_extra( extra: &mut HashMap, key: &str, default_value: &str, ) { let needs_update = match extra.get(key) { Some(existing) => existing .as_str() .map(|value| value.trim().is_empty()) .unwrap_or(true), None => true, }; if needs_update { extra.insert( key.to_string(), serde_json::Value::String(default_value.to_string()), ); } } fn normalize_local_provider_config(provider: &mut ProviderConfig) { if provider.provider_type.trim().is_empty() || provider.provider_type != "ollama" { provider.provider_type = "ollama".to_string(); } ensure_numeric_extra( &mut provider.extra, "list_ttl_secs", DEFAULT_PROVIDER_LIST_TTL_SECS, ); ensure_numeric_extra( &mut provider.extra, "default_context_window", u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), ); } fn normalize_cloud_provider_config(provider: &mut ProviderConfig) { if provider.provider_type.trim().is_empty() || !provider.provider_type.eq_ignore_ascii_case("ollama_cloud") { provider.provider_type = "ollama_cloud".to_string(); } match provider .base_url .as_ref() .map(|value| value.trim_end_matches('/')) { None => { provider.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); } Some(current) if current.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_BASE_URL) => { provider.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); } _ => {} } if provider .api_key_env .as_ref() .map(|value| value.trim().is_empty()) .unwrap_or(true) { provider.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); } ensure_string_extra( &mut provider.extra, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_CLOUD_BASE_URL, ); ensure_numeric_extra( &mut provider.extra, "hourly_quota_tokens", DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA, ); ensure_numeric_extra( &mut provider.extra, "weekly_quota_tokens", DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA, ); ensure_numeric_extra( &mut provider.extra, "list_ttl_secs", DEFAULT_PROVIDER_LIST_TTL_SECS, ); ensure_numeric_extra( &mut provider.extra, "default_context_window", u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), ); } fn normalize_provider_key(name: &str) -> String { let normalized = name.trim().to_ascii_lowercase(); match normalized.as_str() { "ollama" | "ollama-local" => "ollama_local".to_string(), "ollama_cloud" | "ollama-cloud" => "ollama_cloud".to_string(), other => other.replace('-', "_"), } } fn canonical_provider_type(key: &str) -> String { match key { "ollama_local" => "ollama".to_string(), other => other.to_string(), } } fn is_blank(value: &Option) -> bool { value.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) } fn migrate_legacy_provider_tables(document: &mut toml::Value) { let Some(table) = document.as_table_mut() else { return; }; let mut legacy = Vec::new(); for key in ["ollama", "ollama_cloud", "ollama-cloud"] { if let Some(entry) = table.remove(key) { legacy.push((key.to_string(), entry)); } } if legacy.is_empty() { return; } let providers_entry = table .entry("providers".to_string()) .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); if let Some(providers_table) = providers_entry.as_table_mut() { for (key, value) in legacy { providers_table.insert(key, value); } } } fn is_cloud_base_url(base_url: Option<&String>) -> bool { base_url .map(|url| { let trimmed = url.trim_end_matches('/'); trimmed == OLLAMA_CLOUD_BASE_URL || trimmed == LEGACY_OLLAMA_CLOUD_BASE_URL || trimmed.starts_with("https://ollama.com/") || trimmed.starts_with("https://api.ollama.com/") }) .unwrap_or(false) } fn validate_mcp_server_entry(server: &McpServerConfig, scope: McpConfigScope) -> Result<()> { if server.name.trim().is_empty() { return Err(Error::Config(format!( "Each MCP server entry must include a non-empty name (scope: {scope})" ))); } if server.command.trim().is_empty() { return Err(Error::Config(format!( "MCP server '{}' must define a command or endpoint (scope: {scope})", server.name ))); } let transport = server.transport.to_lowercase(); if !matches!(transport.as_str(), "stdio" | "http" | "websocket") { return Err(Error::Config(format!( "Unknown MCP transport '{}' for server '{}' (scope: {scope})", server.transport, server.name ))); } if let Some(oauth) = &server.oauth { if oauth.client_id.trim().is_empty() { return Err(Error::Config(format!( "MCP server '{}' defines OAuth without a client_id", server.name ))); } if oauth.authorize_url.trim().is_empty() { return Err(Error::Config(format!( "MCP server '{}' defines OAuth without an authorize_url", server.name ))); } if oauth.token_url.trim().is_empty() { return Err(Error::Config(format!( "MCP server '{}' defines OAuth without a token_url", server.name ))); } if oauth.device_authorization_url.is_none() && oauth.redirect_url.is_none() { return Err(Error::Config(format!( "MCP server '{}' must define either device_authorization_url or redirect_url for OAuth flows", server.name ))); } } Ok(()) } fn expand_provider_entry(provider_name: &str, provider: &mut ProviderConfig) -> Result<()> { if let Some(ref mut base_url) = provider.base_url { let expanded = expand_env_string( base_url.as_str(), &format!("providers.{provider_name}.base_url"), )?; *base_url = expanded; } if let Some(ref mut api_key) = provider.api_key { let expanded = expand_env_string( api_key.as_str(), &format!("providers.{provider_name}.api_key"), )?; *api_key = expanded; } for (extra_key, extra_value) in provider.extra.iter_mut() { if let serde_json::Value::String(current) = extra_value { let expanded = expand_env_string( current.as_str(), &format!("providers.{provider_name}.{}", extra_key), )?; *current = expanded; } } Ok(()) } fn expand_mcp_servers(servers: &mut [McpServerConfig], field_path: &str) -> Result<()> { for (idx, server) in servers.iter_mut().enumerate() { expand_mcp_server_entry(server, field_path, idx)?; } Ok(()) } fn expand_mcp_server_entry( server: &mut McpServerConfig, field_path: &str, index: usize, ) -> Result<()> { server.command = expand_env_string( server.command.as_str(), &format!("{field_path}[{index}].command"), )?; for (arg_idx, arg) in server.args.iter_mut().enumerate() { *arg = expand_env_string( arg.as_str(), &format!("{field_path}[{index}].args[{arg_idx}]"), )?; } for (env_key, env_value) in server.env.iter_mut() { *env_value = expand_env_string( env_value.as_str(), &format!("{field_path}[{index}].env.{env_key}"), )?; } if let Some(oauth) = server.oauth.as_mut() { oauth.client_id = expand_env_string( oauth.client_id.as_str(), &format!("{field_path}[{index}].oauth.client_id"), )?; oauth.authorize_url = expand_env_string( oauth.authorize_url.as_str(), &format!("{field_path}[{index}].oauth.authorize_url"), )?; oauth.token_url = expand_env_string( oauth.token_url.as_str(), &format!("{field_path}[{index}].oauth.token_url"), )?; if let Some(secret) = oauth.client_secret.as_mut() { *secret = expand_env_string( secret.as_str(), &format!("{field_path}[{index}].oauth.client_secret"), )?; } if let Some(device_url) = oauth.device_authorization_url.as_mut() { *device_url = expand_env_string( device_url.as_str(), &format!("{field_path}[{index}].oauth.device_authorization_url"), )?; } if let Some(redirect) = oauth.redirect_url.as_mut() { *redirect = expand_env_string( redirect.as_str(), &format!("{field_path}[{index}].oauth.redirect_url"), )?; } if let Some(token_env) = oauth.token_env.as_mut() { *token_env = expand_env_string( token_env.as_str(), &format!("{field_path}[{index}].oauth.token_env"), )?; } if let Some(header) = oauth.header.as_mut() { *header = expand_env_string( header.as_str(), &format!("{field_path}[{index}].oauth.header"), )?; } if let Some(prefix) = oauth.header_prefix.as_mut() { *prefix = expand_env_string( prefix.as_str(), &format!("{field_path}[{index}].oauth.header_prefix"), )?; } for (scope_idx, scope) in oauth.scopes.iter_mut().enumerate() { *scope = expand_env_string( scope.as_str(), &format!("{field_path}[{index}].oauth.scopes[{scope_idx}]"), )?; } } Ok(()) } fn expand_mcp_resources(resources: &mut [McpResourceConfig], field_path: &str) -> Result<()> { for (idx, resource) in resources.iter_mut().enumerate() { expand_mcp_resource_entry(resource, field_path, idx)?; } Ok(()) } fn expand_mcp_resource_entry( resource: &mut McpResourceConfig, field_path: &str, index: usize, ) -> Result<()> { resource.server = expand_env_string( resource.server.as_str(), &format!("{field_path}[{index}].server"), )?; resource.uri = expand_env_string(resource.uri.as_str(), &format!("{field_path}[{index}].uri"))?; if let Some(title) = resource.title.as_mut() { *title = expand_env_string(title.as_str(), &format!("{field_path}[{index}].title"))?; } if let Some(description) = resource.description.as_mut() { *description = expand_env_string( description.as_str(), &format!("{field_path}[{index}].description"), )?; } Ok(()) } fn expand_env_string(input: &str, field_path: &str) -> Result { if !input.contains('$') { return Ok(input.to_string()); } match shellexpand::env(input) { Ok(expanded) => Ok(expanded.into_owned()), Err(err) => match err.cause { std::env::VarError::NotPresent => Err(crate::Error::Config(format!( "Environment variable {} referenced in {field_path} is not set", err.var_name ))), std::env::VarError::NotUnicode(_) => Err(crate::Error::Config(format!( "Environment variable {} referenced in {field_path} contains invalid Unicode", err.var_name ))), }, } } /// Default configuration path with user home expansion pub fn default_config_path() -> PathBuf { if let Some(config_dir) = dirs::config_dir() { return config_dir.join("owlen").join("config.toml"); } PathBuf::from(shellexpand::tilde(DEFAULT_CONFIG_PATH).as_ref()) } #[derive(Serialize, Deserialize, Default, Clone)] struct McpConfigFile { #[serde(default)] servers: Vec, #[serde(default)] resources: Vec, } #[derive(Serialize, Deserialize)] #[serde(untagged)] enum McpConfigEnvelope { Array(Vec), Object(McpConfigFile), } fn read_scope_config(path: &Path) -> Result { if !path.exists() { return Ok(McpConfigFile::default()); } let contents = fs::read_to_string(path).map_err(Error::Io)?; if contents.trim().is_empty() { return Ok(McpConfigFile::default()); } let doc: McpConfigEnvelope = serde_json::from_str(&contents).map_err(|err| { Error::Config(format!( "Failed to parse MCP configuration at {}: {err}", path.display() )) })?; Ok(match doc { McpConfigEnvelope::Array(servers) => McpConfigFile { servers, resources: Vec::new(), }, McpConfigEnvelope::Object(doc) => doc, }) } fn write_scope_config(path: &Path, file: &McpConfigFile) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(Error::Io)?; } let serialized = serde_json::to_string_pretty(file).map_err(|err| { Error::Config(format!( "Failed to serialize MCP configuration for {}: {err}", path.display() )) })?; fs::write(path, serialized).map_err(Error::Io) } /// Resolve the configuration file path for a given scope. pub fn mcp_scope_path(scope: McpConfigScope, project_hint: Option<&Path>) -> Option { match scope { McpConfigScope::User => dirs::config_dir() .or_else(|| Some(PathBuf::from(shellexpand::tilde("~/.config").as_ref()))) .map(|dir| dir.join("owlen").join("mcp.json")), McpConfigScope::Project | McpConfigScope::Local => { let root = project_hint .map(PathBuf::from) .or_else(|| discover_project_root(None))?; if matches!(scope, McpConfigScope::Project) { Some(root.join(".mcp.json")) } else { Some(root.join(".owlen").join("mcp.local.json")) } } } } fn discover_project_root(start: Option<&Path>) -> Option { let mut current = start .map(PathBuf::from) .or_else(|| std::env::current_dir().ok())?; loop { if current.join(".mcp.json").exists() || current.join(".owlen").exists() || current.join(".git").exists() || current.join("Cargo.toml").exists() { return Some(current); } if !current.pop() { break; } } start .map(PathBuf::from) .or_else(|| std::env::current_dir().ok()) } /// General behaviour settings shared across clients #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeneralSettings { /// Default provider name for routing pub default_provider: String, /// Optional default model id #[serde(default)] pub default_model: Option, /// Whether streaming responses are preferred #[serde(default = "GeneralSettings::default_streaming")] pub enable_streaming: bool, /// Optional path to a project context file automatically injected as system prompt #[serde(default)] pub project_context_file: Option, /// TTL for cached model listings in seconds #[serde(default = "GeneralSettings::default_model_cache_ttl")] pub model_cache_ttl_secs: u64, } impl GeneralSettings { fn default_streaming() -> bool { true } fn default_model_cache_ttl() -> u64 { 60 } /// Duration representation of model cache TTL pub fn model_cache_ttl(&self) -> Duration { Duration::from_secs(self.model_cache_ttl_secs.max(5)) } } impl Default for GeneralSettings { fn default() -> Self { Self { default_provider: "ollama_local".to_string(), default_model: Some("llama3.2:latest".to_string()), enable_streaming: Self::default_streaming(), project_context_file: Some("OWLEN.md".to_string()), model_cache_ttl_secs: Self::default_model_cache_ttl(), } } } /// Operating modes for the MCP subsystem. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum McpMode { /// Prefer remote MCP servers when configured, but allow local fallback. #[serde(alias = "enabled", alias = "auto")] RemotePreferred, /// Require a configured remote MCP server; fail if none are available. RemoteOnly, /// Always use the in-process MCP server for tooling. #[serde(alias = "local")] LocalOnly, /// Compatibility shim for pre-v1.0 behaviour; treated as `local_only`. Legacy, /// Disable MCP entirely (not recommended). Disabled, } impl Default for McpMode { fn default() -> Self { Self::RemotePreferred } } impl McpMode { /// Whether this mode requires a remote MCP server. pub const fn requires_remote(self) -> bool { matches!(self, Self::RemoteOnly) } /// Whether this mode prefers to use a remote MCP server when available. pub const fn prefers_remote(self) -> bool { matches!(self, Self::RemotePreferred | Self::RemoteOnly) } /// Whether this mode should operate purely locally. pub const fn is_local(self) -> bool { matches!(self, Self::LocalOnly | Self::Legacy) } } /// MCP (Multi-Client-Provider) settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpSettings { /// Operating mode for MCP integration. #[serde(default)] pub mode: McpMode, /// Allow falling back to the local MCP client when remote startup fails. #[serde(default = "McpSettings::default_allow_fallback")] pub allow_fallback: bool, /// Emit a warning when the deprecated `legacy` mode is used. #[serde(default = "McpSettings::default_warn_on_legacy")] pub warn_on_legacy: bool, } impl McpSettings { const fn default_allow_fallback() -> bool { true } const fn default_warn_on_legacy() -> bool { true } fn apply_backward_compat(&mut self) { if self.mode == McpMode::Legacy && self.warn_on_legacy { log::warn!( "MCP legacy mode detected. This mode will be removed in a future release; \ switch to 'local_only' or 'remote_preferred' after verifying your setup." ); } } } impl Default for McpSettings { fn default() -> Self { let mut settings = Self { mode: McpMode::default(), allow_fallback: Self::default_allow_fallback(), warn_on_legacy: Self::default_warn_on_legacy(), }; settings.apply_backward_compat(); settings } } /// Privacy controls governing network access and storage #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrivacySettings { #[serde(default = "PrivacySettings::default_remote_search")] pub enable_remote_search: bool, #[serde(default)] pub cache_web_results: bool, #[serde(default)] pub retain_history_days: u32, #[serde(default = "PrivacySettings::default_require_consent")] pub require_consent_per_session: bool, #[serde(default = "PrivacySettings::default_encrypt_local_data")] pub encrypt_local_data: bool, } impl PrivacySettings { const fn default_remote_search() -> bool { false } const fn default_require_consent() -> bool { true } const fn default_encrypt_local_data() -> bool { true } } impl Default for PrivacySettings { fn default() -> Self { Self { enable_remote_search: Self::default_remote_search(), cache_web_results: false, retain_history_days: 0, require_consent_per_session: Self::default_require_consent(), encrypt_local_data: Self::default_encrypt_local_data(), } } } /// Security settings that constrain tool execution #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecuritySettings { #[serde(default = "SecuritySettings::default_enable_sandboxing")] pub enable_sandboxing: bool, #[serde(default = "SecuritySettings::default_timeout")] pub sandbox_timeout_seconds: u64, #[serde(default = "SecuritySettings::default_max_memory")] pub max_memory_mb: u64, #[serde(default = "SecuritySettings::default_allowed_tools")] pub allowed_tools: Vec, } impl SecuritySettings { const fn default_enable_sandboxing() -> bool { true } const fn default_timeout() -> u64 { 30 } const fn default_max_memory() -> u64 { 512 } fn default_allowed_tools() -> Vec { vec![ WEB_SEARCH_TOOL_NAME.to_string(), "web_scrape".to_string(), "code_exec".to_string(), "file_write".to_string(), "file_delete".to_string(), ] } } impl Default for SecuritySettings { fn default() -> Self { Self { enable_sandboxing: Self::default_enable_sandboxing(), sandbox_timeout_seconds: Self::default_timeout(), max_memory_mb: Self::default_max_memory(), allowed_tools: Self::default_allowed_tools(), } } } /// Per-tool configuration toggles #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ToolSettings { #[serde(default)] pub web_search: WebSearchToolConfig, #[serde(default)] pub code_exec: CodeExecToolConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebSearchToolConfig { #[serde(default)] pub enabled: bool, #[serde(default)] pub api_key: String, #[serde(default = "WebSearchToolConfig::default_max_results")] pub max_results: u32, } impl WebSearchToolConfig { const fn default_max_results() -> u32 { 5 } } impl Default for WebSearchToolConfig { fn default() -> Self { Self { enabled: false, api_key: String::new(), max_results: Self::default_max_results(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CodeExecToolConfig { #[serde(default)] pub enabled: bool, #[serde(default = "CodeExecToolConfig::default_allowed_languages")] pub allowed_languages: Vec, #[serde(default = "CodeExecToolConfig::default_timeout")] pub timeout_seconds: u64, } impl CodeExecToolConfig { fn default_allowed_languages() -> Vec { vec!["python".to_string(), "javascript".to_string()] } const fn default_timeout() -> u64 { 30 } } impl Default for CodeExecToolConfig { fn default() -> Self { Self { enabled: false, allowed_languages: Self::default_allowed_languages(), timeout_seconds: Self::default_timeout(), } } } /// UI preferences that consumers can respect as needed #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UiSettings { #[serde(default = "UiSettings::default_theme")] pub theme: String, #[serde(default = "UiSettings::default_word_wrap")] pub word_wrap: bool, #[serde(default = "UiSettings::default_max_history_lines")] pub max_history_lines: usize, #[serde( rename = "role_label", alias = "show_role_labels", default = "UiSettings::default_role_label_mode", deserialize_with = "UiSettings::deserialize_role_label_mode" )] pub role_label_mode: RoleLabelDisplay, #[serde(default = "UiSettings::default_wrap_column")] pub wrap_column: u16, #[serde(default = "UiSettings::default_show_onboarding")] pub show_onboarding: bool, #[serde(default = "UiSettings::default_input_max_rows")] pub input_max_rows: u16, #[serde(default = "UiSettings::default_scrollback_lines")] pub scrollback_lines: usize, #[serde(default = "UiSettings::default_show_cursor_outside_insert")] pub show_cursor_outside_insert: bool, #[serde(default = "UiSettings::default_syntax_highlighting")] pub syntax_highlighting: bool, #[serde(default = "UiSettings::default_render_markdown")] pub render_markdown: bool, #[serde(default = "UiSettings::default_show_timestamps")] pub show_timestamps: bool, #[serde(default = "UiSettings::default_icon_mode")] pub icon_mode: IconMode, #[serde(default = "UiSettings::default_keymap_profile")] pub keymap_profile: Option, #[serde(default = "UiSettings::default_keymap_leader")] pub keymap_leader: String, #[serde(default)] pub keymap_path: Option, #[serde(default)] pub accessibility: AccessibilitySettings, #[serde(default)] pub layers: LayerSettings, #[serde(default)] pub animations: AnimationSettings, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccessibilitySettings { #[serde(default = "AccessibilitySettings::default_high_contrast")] pub high_contrast: bool, #[serde(default = "AccessibilitySettings::default_reduced_chrome")] pub reduced_chrome: bool, } impl AccessibilitySettings { const fn default_high_contrast() -> bool { false } const fn default_reduced_chrome() -> bool { false } } impl Default for AccessibilitySettings { fn default() -> Self { Self { high_contrast: Self::default_high_contrast(), reduced_chrome: Self::default_reduced_chrome(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LayerSettings { #[serde(default = "LayerSettings::default_shadow_elevation")] pub shadow_elevation: u8, #[serde(default = "LayerSettings::default_glass_tint")] pub glass_tint: f32, #[serde(default = "LayerSettings::default_neon_intensity")] pub neon_intensity: u8, #[serde(default = "LayerSettings::default_focus_ring")] pub focus_ring: bool, } impl LayerSettings { const fn default_shadow_elevation() -> u8 { 2 } const fn default_neon_intensity() -> u8 { 60 } const fn default_focus_ring() -> bool { true } const fn default_glass_tint() -> f32 { 0.82 } pub fn shadow_depth(&self) -> u8 { self.shadow_elevation.min(3) } pub fn neon_factor(&self) -> f64 { (self.neon_intensity as f64).clamp(0.0, 100.0) / 100.0 } pub fn glass_tint_factor(&self) -> f64 { self.glass_tint.clamp(0.0, 1.0) as f64 } } impl Default for LayerSettings { fn default() -> Self { Self { shadow_elevation: Self::default_shadow_elevation(), glass_tint: Self::default_glass_tint(), neon_intensity: Self::default_neon_intensity(), focus_ring: Self::default_focus_ring(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnimationSettings { #[serde(default = "AnimationSettings::default_micro")] pub micro: bool, #[serde(default = "AnimationSettings::default_gauge_smoothing")] pub gauge_smoothing: f32, #[serde(default = "AnimationSettings::default_pane_decay")] pub pane_decay: f32, } impl AnimationSettings { const fn default_micro() -> bool { true } const fn default_gauge_smoothing() -> f32 { 0.24 } const fn default_pane_decay() -> f32 { 0.68 } pub fn gauge_smoothing_factor(&self) -> f64 { self.gauge_smoothing.clamp(0.05, 1.0) as f64 } pub fn pane_decay_factor(&self) -> f64 { self.pane_decay.clamp(0.2, 0.95) as f64 } } impl Default for AnimationSettings { fn default() -> Self { Self { micro: Self::default_micro(), gauge_smoothing: Self::default_gauge_smoothing(), pane_decay: Self::default_pane_decay(), } } } /// Preference for which symbol set to render in the terminal UI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum IconMode { /// Automatically detect support for Nerd Font glyphs. #[default] Auto, /// Use only ASCII-safe symbols. Ascii, /// Force Nerd Font glyphs regardless of detection heuristics. Nerd, } impl UiSettings { fn default_theme() -> String { "default_dark".to_string() } fn default_word_wrap() -> bool { true } fn default_max_history_lines() -> usize { 2000 } const fn default_role_label_mode() -> RoleLabelDisplay { RoleLabelDisplay::Above } fn default_wrap_column() -> u16 { 100 } const fn default_show_onboarding() -> bool { true } const fn default_input_max_rows() -> u16 { 5 } const fn default_scrollback_lines() -> usize { 2000 } const fn default_show_cursor_outside_insert() -> bool { false } const fn default_syntax_highlighting() -> bool { true } const fn default_render_markdown() -> bool { true } const fn default_show_timestamps() -> bool { true } const fn default_icon_mode() -> IconMode { IconMode::Auto } fn default_keymap_profile() -> Option { None } fn default_keymap_leader() -> String { "Space".to_string() } fn deserialize_role_label_mode<'de, D>( deserializer: D, ) -> std::result::Result where D: Deserializer<'de>, { struct RoleLabelModeVisitor; impl<'de> Visitor<'de> for RoleLabelModeVisitor { type Value = RoleLabelDisplay; fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("`inline`, `above`, `none`, or a legacy boolean") } fn visit_str(self, v: &str) -> std::result::Result where E: de::Error, { match v.trim().to_ascii_lowercase().as_str() { "inline" => Ok(RoleLabelDisplay::Inline), "above" => Ok(RoleLabelDisplay::Above), "none" => Ok(RoleLabelDisplay::None), other => Err(de::Error::unknown_variant( other, &["inline", "above", "none"], )), } } fn visit_string(self, v: String) -> std::result::Result where E: de::Error, { self.visit_str(&v) } fn visit_bool(self, v: bool) -> std::result::Result where E: de::Error, { if v { Ok(RoleLabelDisplay::Above) } else { Ok(RoleLabelDisplay::None) } } } deserializer.deserialize_any(RoleLabelModeVisitor) } } impl Default for UiSettings { fn default() -> Self { Self { theme: Self::default_theme(), word_wrap: Self::default_word_wrap(), max_history_lines: Self::default_max_history_lines(), role_label_mode: Self::default_role_label_mode(), wrap_column: Self::default_wrap_column(), show_onboarding: Self::default_show_onboarding(), input_max_rows: Self::default_input_max_rows(), scrollback_lines: Self::default_scrollback_lines(), show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), syntax_highlighting: Self::default_syntax_highlighting(), render_markdown: Self::default_render_markdown(), show_timestamps: Self::default_show_timestamps(), icon_mode: Self::default_icon_mode(), keymap_profile: Self::default_keymap_profile(), keymap_leader: Self::default_keymap_leader(), keymap_path: None, accessibility: AccessibilitySettings::default(), layers: LayerSettings::default(), animations: AnimationSettings::default(), } } } /// Storage related preferences #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageSettings { #[serde(default = "StorageSettings::default_conversation_dir")] pub conversation_dir: Option, #[serde(default = "StorageSettings::default_auto_save")] pub auto_save_sessions: bool, #[serde(default = "StorageSettings::default_max_sessions")] pub max_saved_sessions: usize, #[serde(default = "StorageSettings::default_session_timeout")] pub session_timeout_minutes: u64, #[serde(default = "StorageSettings::default_generate_descriptions")] pub generate_descriptions: bool, } impl StorageSettings { fn default_conversation_dir() -> Option { None } fn default_auto_save() -> bool { true } fn default_max_sessions() -> usize { 25 } fn default_session_timeout() -> u64 { 120 } fn default_generate_descriptions() -> bool { true } /// Resolve storage directory path /// Uses platform-specific data directory if not explicitly configured: /// - Linux: ~/.local/share/owlen/sessions /// - Windows: %APPDATA%\owlen\sessions /// - macOS: ~/Library/Application Support/owlen/sessions pub fn conversation_path(&self) -> PathBuf { if let Some(ref dir) = self.conversation_dir { PathBuf::from(shellexpand::tilde(dir).as_ref()) } else { // Use platform-specific data directory dirs::data_local_dir() .map(|d| d.join("owlen").join("sessions")) .unwrap_or_else(|| PathBuf::from("./owlen_sessions")) } } } impl Default for StorageSettings { fn default() -> Self { Self { conversation_dir: None, // Use platform-specific defaults auto_save_sessions: Self::default_auto_save(), max_saved_sessions: Self::default_max_sessions(), session_timeout_minutes: Self::default_session_timeout(), generate_descriptions: Self::default_generate_descriptions(), } } } /// Input handling preferences shared across clients #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InputSettings { #[serde(default = "InputSettings::default_multiline")] pub multiline: bool, #[serde(default = "InputSettings::default_history_size")] pub history_size: usize, #[serde(default = "InputSettings::default_tab_width")] pub tab_width: u8, #[serde(default = "InputSettings::default_confirm_send")] pub confirm_send: bool, } impl InputSettings { fn default_multiline() -> bool { true } fn default_history_size() -> usize { 100 } fn default_tab_width() -> u8 { 4 } fn default_confirm_send() -> bool { false } } impl Default for InputSettings { fn default() -> Self { Self { multiline: Self::default_multiline(), history_size: Self::default_history_size(), tab_width: Self::default_tab_width(), confirm_send: Self::default_confirm_send(), } } } /// Convenience accessor for an Ollama provider entry, creating a default if missing pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig { ensure_provider_config(config, "ollama_local") } /// Ensure a provider configuration exists for the requested provider name and return a mutable reference. pub fn ensure_provider_config_mut<'a>( config: &'a mut Config, provider_name: &str, ) -> &'a mut ProviderConfig { let key = normalize_provider_key(provider_name); let entry = config.providers.entry(key.clone()).or_insert_with(|| { let mut default = default_provider_config_for(&key).unwrap_or_else(|| ProviderConfig { enabled: true, provider_type: canonical_provider_type(&key), base_url: None, api_key: None, api_key_env: None, extra: HashMap::new(), }); if default.provider_type.is_empty() { default.provider_type = canonical_provider_type(&key); } default }); if entry.provider_type.is_empty() { entry.provider_type = canonical_provider_type(&key); } entry } /// Ensure a provider configuration exists for the requested provider name pub fn ensure_provider_config<'a>( config: &'a mut Config, provider_name: &str, ) -> &'a ProviderConfig { let entry = ensure_provider_config_mut(config, provider_name); &*entry } /// Calculate absolute timeout for session data based on configuration pub fn session_timeout(config: &Config) -> Duration { Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60) } #[cfg(test)] mod tests { use super::*; #[test] fn expand_provider_env_vars_resolves_api_key() { unsafe { std::env::set_var("OWLEN_TEST_API_KEY", "super-secret"); } let mut config = Config::default(); if let Some(ollama_local) = config.providers.get_mut("ollama_local") { ollama_local.api_key = Some("${OWLEN_TEST_API_KEY}".to_string()); } config .expand_provider_env_vars() .expect("environment expansion succeeded"); assert_eq!( config.providers["ollama_local"].api_key.as_deref(), Some("super-secret") ); unsafe { std::env::remove_var("OWLEN_TEST_API_KEY"); } } #[test] fn expand_provider_env_vars_errors_for_missing_variable() { unsafe { std::env::remove_var("OWLEN_TEST_MISSING"); } let mut config = Config::default(); if let Some(ollama_local) = config.providers.get_mut("ollama_local") { ollama_local.api_key = Some("${OWLEN_TEST_MISSING}".to_string()); } let error = config .expand_provider_env_vars() .expect_err("missing variables should error"); match error { crate::Error::Config(message) => { assert!(message.contains("OWLEN_TEST_MISSING")); } other => panic!("expected config error, got {other:?}"), } } #[test] fn default_config_sets_provider_extras() { let config = Config::default(); let local = config .providers .get("ollama_local") .expect("local provider"); assert_eq!( local .extra .get("list_ttl_secs") .and_then(|value| value.as_u64()), Some(DEFAULT_PROVIDER_LIST_TTL_SECS) ); assert_eq!( local .extra .get("default_context_window") .and_then(|value| value.as_u64()), Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) ); let cloud = config .providers .get("ollama_cloud") .expect("cloud provider"); assert_eq!( cloud .extra .get("list_ttl_secs") .and_then(|value| value.as_u64()), Some(DEFAULT_PROVIDER_LIST_TTL_SECS) ); assert_eq!( cloud .extra .get("default_context_window") .and_then(|value| value.as_u64()), Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) ); assert_eq!( cloud .extra .get("hourly_quota_tokens") .and_then(|value| value.as_u64()), Some(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA) ); assert_eq!( cloud .extra .get("weekly_quota_tokens") .and_then(|value| value.as_u64()), Some(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA) ); assert_eq!( cloud .extra .get(OLLAMA_CLOUD_ENDPOINT_KEY) .and_then(|value| value.as_str()), Some(OLLAMA_CLOUD_BASE_URL) ); } #[test] fn ensure_defaults_backfills_missing_provider_metadata() { let mut config = Config::default(); if let Some(local) = config.providers.get_mut("ollama_local") { local.extra.remove("list_ttl_secs"); local.extra.insert( "default_context_window".into(), serde_json::Value::String("".into()), ); local.provider_type.clear(); } if let Some(cloud) = config.providers.get_mut("ollama_cloud") { cloud.extra.remove("list_ttl_secs"); cloud.extra.insert( "default_context_window".into(), serde_json::Value::String("invalid".into()), ); cloud.extra.remove("hourly_quota_tokens"); cloud.extra.remove("weekly_quota_tokens"); cloud.extra.remove(OLLAMA_CLOUD_ENDPOINT_KEY); cloud.api_key_env = None; cloud.provider_type.clear(); cloud.base_url = Some(LEGACY_OLLAMA_CLOUD_BASE_URL.to_string()); } config.ensure_defaults(); let local = config .providers .get("ollama_local") .expect("local provider"); assert_eq!(local.provider_type, "ollama"); assert_eq!( local .extra .get("list_ttl_secs") .and_then(|value| value.as_u64()), Some(DEFAULT_PROVIDER_LIST_TTL_SECS) ); assert_eq!( local .extra .get("default_context_window") .and_then(|value| value.as_u64()), Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) ); let cloud = config .providers .get("ollama_cloud") .expect("cloud provider"); assert_eq!(cloud.provider_type, "ollama_cloud"); assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV)); assert_eq!( cloud .extra .get("list_ttl_secs") .and_then(|value| value.as_u64()), Some(DEFAULT_PROVIDER_LIST_TTL_SECS) ); assert_eq!( cloud .extra .get("default_context_window") .and_then(|value| value.as_u64()), Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) ); assert_eq!( cloud .extra .get("hourly_quota_tokens") .and_then(|value| value.as_u64()), Some(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA) ); assert_eq!( cloud .extra .get("weekly_quota_tokens") .and_then(|value| value.as_u64()), Some(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA) ); assert_eq!( cloud .extra .get(OLLAMA_CLOUD_ENDPOINT_KEY) .and_then(|value| value.as_str()), Some(OLLAMA_CLOUD_BASE_URL) ); } #[test] fn test_storage_platform_specific_paths() { let config = Config::default(); let path = config.storage.conversation_path(); // Verify it contains owlen/sessions assert!(path.to_string_lossy().contains("owlen")); assert!(path.to_string_lossy().contains("sessions")); // Platform-specific checks #[cfg(target_os = "linux")] { // Linux should use ~/.local/share/owlen/sessions assert!(path.to_string_lossy().contains(".local/share")); } #[cfg(target_os = "windows")] { // Windows should use AppData assert!(path.to_string_lossy().contains("AppData")); } #[cfg(target_os = "macos")] { // macOS should use ~/Library/Application Support assert!( path.to_string_lossy() .contains("Library/Application Support") ); } println!("Config conversation path: {}", path.display()); } #[test] fn test_storage_custom_path() { let mut config = Config::default(); config.storage.conversation_dir = Some("~/custom/path".to_string()); let path = config.storage.conversation_path(); assert!(path.to_string_lossy().contains("custom/path")); } #[test] fn default_config_contains_local_provider() { let config = Config::default(); let local = config .providers .get("ollama_local") .expect("default local provider"); assert!(local.enabled); assert_eq!(local.base_url.as_deref(), Some(OLLAMA_LOCAL_BASE_URL)); let cloud = config .providers .get("ollama_cloud") .expect("default cloud provider"); assert!(!cloud.enabled); assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV)); } #[test] fn default_ui_accessibility_flags_off() { let config = Config::default(); assert!(!config.ui.accessibility.high_contrast); assert!(!config.ui.accessibility.reduced_chrome); } #[test] fn ensure_provider_config_aliases_cloud_defaults() { let mut config = Config::default(); config.providers.clear(); let cloud = ensure_provider_config(&mut config, "ollama-cloud"); assert_eq!(cloud.provider_type, "ollama_cloud"); assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV)); assert!(config.providers.contains_key("ollama_cloud")); assert!(!config.providers.contains_key("ollama-cloud")); } #[test] fn migrate_ollama_cloud_underscore_key() { let mut config = Config::default(); config.providers.clear(); config.providers.insert( "ollama_cloud".to_string(), ProviderConfig { enabled: true, provider_type: "ollama_cloud".to_string(), base_url: Some("https://api.ollama.com".to_string()), api_key: Some("secret".to_string()), api_key_env: None, extra: HashMap::new(), }, ); config.apply_schema_migrations("1.0.0"); assert!(config.providers.get("ollama_cloud").is_some()); let cloud = config .providers .get("ollama_cloud") .expect("migrated config"); assert!(cloud.enabled); assert_eq!(cloud.provider_type, "ollama_cloud"); assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); assert_eq!(cloud.api_key.as_deref(), Some("secret")); } #[test] fn migration_sets_cloud_mode_for_cloud_base() { let mut config = Config::default(); if let Some(ollama) = config.providers.get_mut("ollama_local") { ollama.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); } config.apply_schema_migrations("1.4.0"); let cloud = config .providers .get("ollama_cloud") .expect("cloud provider created"); assert!(cloud.enabled); assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV)); } #[test] fn migrate_legacy_monolithic_ollama_entry() { let mut config = Config::default(); config.providers.clear(); config.providers.insert( "ollama".to_string(), ProviderConfig { enabled: true, provider_type: "ollama".to_string(), base_url: Some(OLLAMA_LOCAL_BASE_URL.to_string()), api_key: None, api_key_env: None, extra: HashMap::new(), }, ); config.apply_schema_migrations("1.2.0"); let local = config .providers .get("ollama_local") .expect("local provider migrated"); assert!(local.enabled); assert_eq!(local.base_url.as_deref(), Some(OLLAMA_LOCAL_BASE_URL)); let cloud = config .providers .get("ollama_cloud") .expect("cloud provider placeholder"); assert!(!cloud.enabled); } #[test] fn migrate_legacy_provider_tables_moves_top_level_entries() { let mut document: toml::Value = toml::from_str( r#" [ollama] base_url = "http://localhost:11434" [general] default_provider = "ollama" "#, ) .expect("valid inline config"); migrate_legacy_provider_tables(&mut document); let providers = document .get("providers") .and_then(|value| value.as_table()) .expect("providers table present"); assert!(providers.contains_key("ollama")); assert!(providers["ollama"].get("base_url").is_some()); assert!(document.get("ollama").is_none()); } #[test] fn validate_rejects_missing_default_provider() { let mut config = Config::default(); config.general.default_provider = "does-not-exist".to_string(); let result = config.validate(); assert!( matches!(result, Err(crate::Error::Config(message)) if message.contains("Default provider")) ); } #[test] fn validate_rejects_remote_only_without_servers() { let mut config = Config::default(); config.mcp.mode = McpMode::RemoteOnly; config.mcp_servers.clear(); let result = config.validate(); assert!( matches!(result, Err(crate::Error::Config(message)) if message.contains("remote_only")) ); } #[test] fn validate_rejects_unknown_transport() { let mut config = Config::default(); config.mcp_servers = vec![McpServerConfig { name: "bad".into(), command: "binary".into(), transport: "udp".into(), args: Vec::new(), env: std::collections::HashMap::new(), oauth: None, }]; let result = config.validate(); assert!( matches!(result, Err(crate::Error::Config(message)) if message.contains("transport")) ); } #[test] fn validate_accepts_local_only_configuration() { let mut config = Config::default(); config.mcp.mode = McpMode::LocalOnly; assert!(config.validate().is_ok()); } #[test] fn refresh_mcp_servers_merges_scopes_with_precedence() { let temp = tempfile::tempdir().expect("tempdir"); let project_root = temp.path(); std::fs::write( project_root.join(".mcp.json"), r#"{ "servers": [ { "name": "shared", "command": "project-cmd", "transport": "stdio" }, { "name": "project-only", "command": "proj", "transport": "stdio" } ], "resources": [ { "server": "github", "uri": "issue://123", "title": "Project Issue" }, { "server": "docs", "uri": "page://start", "title": "Project Doc" } ] }"#, ) .expect("write project scope"); let local_dir = project_root.join(".owlen"); std::fs::create_dir_all(&local_dir).expect("local dir"); std::fs::write( local_dir.join("mcp.local.json"), r#"{ "servers": [ { "name": "shared", "command": "local-cmd", "transport": "stdio" } ], "resources": [ { "server": "github", "uri": "issue://123", "title": "Local Override" } ] }"#, ) .expect("write local scope"); let mut config = Config::default(); config.mcp_servers.push(McpServerConfig { name: "shared".into(), command: "user-cmd".into(), args: Vec::new(), transport: "stdio".into(), env: std::collections::HashMap::new(), oauth: None, }); config.mcp_resources.push(McpResourceConfig { server: "github".into(), uri: "issue://123".into(), title: Some("User Issue".into()), description: None, }); config .refresh_mcp_servers(Some(project_root)) .expect("refresh scopes"); // We should have four scoped entries (user + two project + local) and precedence should select local assert_eq!(config.scoped_mcp_servers().len(), 4); let effective = config.effective_mcp_servers(); assert_eq!(effective.len(), 2); // shared + project-only assert_eq!(effective[0].command, "local-cmd"); assert_eq!(effective[0].name, "shared"); assert_eq!(config.scoped_mcp_resources().len(), 4); let effective_resources = config.effective_mcp_resources(); assert_eq!(effective_resources.len(), 2); assert_eq!( effective_resources .iter() .find(|res| res.server == "github") .and_then(|res| res.title.as_deref()), Some("Local Override") ); } #[test] fn remove_mcp_server_reports_scope() { let temp = tempfile::tempdir().expect("tempdir"); let project_root = temp.path(); std::fs::write( project_root.join(".mcp.json"), r#"{ "servers": [{ "name": "project", "command": "proj", "transport": "stdio" }] }"#, ) .expect("write project scope"); let mut config = Config::default(); config.mcp_servers.push(McpServerConfig { name: "user".into(), command: "user".into(), args: Vec::new(), transport: "stdio".into(), env: std::collections::HashMap::new(), oauth: None, }); config .refresh_mcp_servers(Some(project_root)) .expect("refresh scopes"); // Remove without specifying scope should pick highest precedence (project) let removed_scope = config .remove_mcp_server(None, "project", Some(project_root)) .expect("remove call"); assert_eq!(removed_scope, Some(McpConfigScope::Project)); // Remove the remaining user scope explicitly let removed_scope = config .remove_mcp_server(Some(McpConfigScope::User), "user", Some(project_root)) .expect("remove user"); assert_eq!(removed_scope, Some(McpConfigScope::User)); } }