use crate::mode::ModeConfig; use crate::provider::ProviderConfig; use crate::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; 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.1.0"; /// 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, } impl Default for Config { fn default() -> Self { let mut providers = HashMap::new(); providers.insert("ollama".to_string(), default_ollama_provider_config()); 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(), } } } /// 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, } impl McpServerConfig { fn default_transport() -> String { "stdio".to_string() } } 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 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()) { if 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.validate()?; Ok(config) } else { let mut config = Config::default(); config.expand_provider_env_vars()?; Ok(config) } } /// Persist configuration to disk pub fn save(&self, path: Option<&Path>) -> Result<()> { self.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> { self.providers.get(name) } /// Update or insert a provider configuration pub fn upsert_provider(&mut self, name: impl Into, config: ProviderConfig) { self.providers.insert(name.into(), 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() { if 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".to_string(); } ensure_provider_config(self, "ollama"); 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(()) } /// 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()?; 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 ); } if let Some(legacy_cloud) = self.providers.remove("ollama_cloud") { self.merge_legacy_ollama_provider(legacy_cloud); } if let Some(legacy_cloud) = self.providers.remove("ollama-cloud") { self.merge_legacy_ollama_provider(legacy_cloud); } self.schema_version = CONFIG_SCHEMA_VERSION.to_string(); } fn merge_legacy_ollama_provider(&mut self, mut legacy_cloud: ProviderConfig) { use std::collections::hash_map::Entry; legacy_cloud.provider_type = "ollama".to_string(); match self.providers.entry("ollama".to_string()) { Entry::Occupied(mut entry) => { let target = entry.get_mut(); if target.base_url.is_none() { target.base_url = legacy_cloud.base_url.take(); } if target.api_key.is_none() { target.api_key = legacy_cloud.api_key.take(); } if target.extra.is_empty() && !legacy_cloud.extra.is_empty() { target.extra = legacy_cloud.extra; } } Entry::Vacant(entry) => { entry.insert(legacy_cloud); } } } 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<()> { match self.mcp.mode { McpMode::RemoteOnly => { if self.mcp_servers.is_empty() { 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 && self.mcp_servers.is_empty() { 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<()> { for server in &self.mcp_servers { if server.name.trim().is_empty() { return Err(crate::Error::Config( "Each [[mcp_servers]] entry must include a non-empty name".to_string(), )); } if server.command.trim().is_empty() { return Err(crate::Error::Config(format!( "MCP server '{}' must define a command or endpoint", server.name ))); } let transport = server.transport.to_lowercase(); if !matches!(transport.as_str(), "stdio" | "http" | "websocket") { return Err(crate::Error::Config(format!( "Unknown MCP transport '{}' for server '{}'", server.transport, server.name ))); } } Ok(()) } } fn default_ollama_provider_config() -> ProviderConfig { ProviderConfig { provider_type: "ollama".to_string(), base_url: Some("http://localhost:11434".to_string()), api_key: None, extra: HashMap::new(), } } 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_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()) } /// 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".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".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(default = "UiSettings::default_show_role_labels")] pub show_role_labels: bool, #[serde(default = "UiSettings::default_wrap_column")] pub wrap_column: u16, #[serde(default = "UiSettings::default_show_onboarding")] pub show_onboarding: bool, } impl UiSettings { fn default_theme() -> String { "default_dark".to_string() } fn default_word_wrap() -> bool { true } fn default_max_history_lines() -> usize { 2000 } fn default_show_role_labels() -> bool { true } fn default_wrap_column() -> u16 { 100 } const fn default_show_onboarding() -> bool { true } } 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(), show_role_labels: Self::default_show_role_labels(), wrap_column: Self::default_wrap_column(), show_onboarding: Self::default_show_onboarding(), } } } /// 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") } /// 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 { use std::collections::hash_map::Entry; if matches!(provider_name, "ollama_cloud" | "ollama-cloud") { return ensure_provider_config(config, "ollama"); } match config.providers.entry(provider_name.to_string()) { Entry::Occupied(entry) => entry.into_mut(), Entry::Vacant(entry) => { let default = match provider_name { "ollama" => default_ollama_provider_config(), other => ProviderConfig { provider_type: other.to_string(), base_url: None, api_key: None, extra: HashMap::new(), }, }; entry.insert(default) } } } /// 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() { std::env::set_var("OWLEN_TEST_API_KEY", "super-secret"); let mut config = Config::default(); if let Some(ollama) = config.providers.get_mut("ollama") { ollama.api_key = Some("${OWLEN_TEST_API_KEY}".to_string()); } config .expand_provider_env_vars() .expect("environment expansion succeeded"); assert_eq!( config.providers["ollama"].api_key.as_deref(), Some("super-secret") ); std::env::remove_var("OWLEN_TEST_API_KEY"); } #[test] fn expand_provider_env_vars_errors_for_missing_variable() { std::env::remove_var("OWLEN_TEST_MISSING"); let mut config = Config::default(); if let Some(ollama) = config.providers.get_mut("ollama") { ollama.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 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(); assert!(config.providers.contains_key("ollama")); } #[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"); assert_eq!(cloud.base_url.as_deref(), Some("http://localhost:11434")); assert!(config.providers.contains_key("ollama")); 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 { provider_type: "ollama_cloud".to_string(), base_url: Some("https://api.ollama.com".to_string()), api_key: Some("secret".to_string()), extra: HashMap::new(), }, ); config.apply_schema_migrations("1.0.0"); assert!(config.providers.get("ollama_cloud").is_none()); assert!(config.providers.get("ollama-cloud").is_none()); let cloud = config.providers.get("ollama").expect("migrated config"); assert_eq!(cloud.provider_type, "ollama"); assert_eq!(cloud.base_url.as_deref(), Some("https://api.ollama.com")); assert_eq!(cloud.api_key.as_deref(), Some("secret")); } #[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(), }]; 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()); } }