use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; /// Get the user configuration directory (~/.picobot) pub fn get_user_config_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".picobot") } /// Get the default workspace directory (~/.picobot/workspace) pub fn get_default_workspace_dir() -> PathBuf { get_user_config_dir().join("workspace") } /// Expand ~ in path to user home directory pub fn expand_path(path: &str) -> PathBuf { if let Some(path) = path.strip_prefix("~/") { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(path) } else { PathBuf::from(path) } } /// Ensure workspace directory exists, create if needed pub fn ensure_workspace_dir(path: &Path) -> Result { if !path.exists() { tracing::info!("Creating workspace directory: {}", path.display()); fs::create_dir_all(path)?; } // Return canonical path path.canonicalize().or_else(|_| Ok(path.to_path_buf())) } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { pub providers: HashMap, pub models: HashMap, pub agents: HashMap, #[serde(default)] pub gateway: GatewayConfig, #[serde(default)] pub client: ClientConfig, #[serde(default)] pub channels: HashMap, #[serde(default)] pub memory: MemoryConfig, #[serde(default = "default_workspace_dir")] pub workspace_dir: String, #[serde(default)] pub mcp: McpConfig, #[serde(default)] pub browser: BrowserConfig, } fn default_workspace_dir() -> String { get_default_workspace_dir().to_string_lossy().to_string() } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FeishuChannelConfig { #[serde(default)] pub enabled: bool, pub app_id: String, pub app_secret: String, #[serde(default = "default_allow_from")] pub allow_from: Vec, /// Require an explicit bot @mention before accepting group-chat messages. #[serde(default = "default_true")] pub require_mention: bool, #[serde(default)] pub agent: String, #[serde(default = "default_media_dir")] pub media_dir: String, /// Emoji type for message reactions (e.g. "THUMBSUP", "OK", "EYES"). #[serde(default = "default_reaction_emoji")] pub reaction_emoji: String, /// Edit one card with latest Turn snapshots instead of sending only the final result. #[serde(default)] pub live_updates: bool, #[serde(default = "default_feishu_live_update_interval_ms")] pub live_update_interval_ms: u64, #[serde(default = "default_feishu_max_image_bytes")] pub max_image_bytes: u64, #[serde(default = "default_feishu_max_file_bytes")] pub max_file_bytes: u64, #[serde(default = "default_feishu_media_dir_max_bytes")] pub media_dir_max_bytes: u64, #[serde(default = "default_feishu_request_timeout_secs")] pub request_timeout_secs: u64, } fn default_allow_from() -> Vec { vec!["*".to_string()] } fn default_media_dir() -> String { get_user_config_dir() .join("media/feishu") .to_string_lossy() .to_string() } fn default_reaction_emoji() -> String { "Typing".to_string() } fn default_feishu_live_update_interval_ms() -> u64 { 500 } fn default_feishu_max_image_bytes() -> u64 { 10 * 1024 * 1024 } fn default_feishu_max_file_bytes() -> u64 { 25 * 1024 * 1024 } fn default_feishu_media_dir_max_bytes() -> u64 { 512 * 1024 * 1024 } fn default_feishu_request_timeout_secs() -> u64 { 30 } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProviderConfig { #[serde(rename = "type")] pub provider_type: String, pub base_url: String, pub api_key: String, #[serde(default)] pub extra_headers: HashMap, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ModelConfig { pub model_id: String, #[serde(default)] pub temperature: Option, #[serde(default)] pub max_tokens: Option, #[serde(default = "default_input_type")] pub input_type: Vec, #[serde(flatten)] pub extra: HashMap, } fn default_input_type() -> Vec { vec!["text".to_string()] } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentConfig { pub provider: String, pub model: String, #[serde(default = "default_max_tool_iterations")] pub max_tool_iterations: usize, #[serde(default = "default_token_limit")] pub token_limit: usize, } fn default_max_tool_iterations() -> usize { 99 } fn default_token_limit() -> usize { 128_000 } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct GatewayConfig { #[serde(default = "default_gateway_host")] pub host: String, #[serde(default = "default_gateway_port")] pub port: u16, #[serde(default = "default_require_pairing")] pub require_pairing: bool, #[serde(default, rename = "session_ttl_hours")] pub session_ttl_hours: Option, #[serde(default, rename = "cleanup_interval_minutes")] pub cleanup_interval_minutes: Option, #[serde(default, rename = "session_db_path")] pub session_db_path: Option, #[serde(default, rename = "max_concurrent_background_tasks")] pub max_concurrent_background_tasks: usize, #[serde(default)] pub scheduler: Option, #[serde(default)] pub file_transfer: FileTransferConfig, } impl Default for GatewayConfig { fn default() -> Self { Self { host: default_gateway_host(), port: default_gateway_port(), require_pairing: default_require_pairing(), session_ttl_hours: None, cleanup_interval_minutes: None, session_db_path: None, max_concurrent_background_tasks: 10, scheduler: None, file_transfer: FileTransferConfig::default(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileTransferConfig { #[serde(default = "default_file_transfer_enabled")] pub enabled: bool, #[serde(default = "default_upload_dir")] pub upload_dir: String, #[serde(default = "default_max_file_bytes")] pub max_file_bytes: u64, #[serde(default = "default_max_files_per_message")] pub max_files_per_message: usize, #[serde(default = "default_max_message_bytes")] pub max_message_bytes: u64, #[serde(default = "default_pending_ttl_seconds")] pub pending_ttl_seconds: u64, } impl Default for FileTransferConfig { fn default() -> Self { Self { enabled: default_file_transfer_enabled(), upload_dir: default_upload_dir(), max_file_bytes: default_max_file_bytes(), max_files_per_message: default_max_files_per_message(), max_message_bytes: default_max_message_bytes(), pending_ttl_seconds: default_pending_ttl_seconds(), } } } fn default_file_transfer_enabled() -> bool { true } fn default_upload_dir() -> String { get_user_config_dir() .join("media/cli_chat") .to_string_lossy() .to_string() } fn default_max_file_bytes() -> u64 { 25 * 1024 * 1024 } fn default_max_files_per_message() -> usize { 8 } fn default_max_message_bytes() -> u64 { 64 * 1024 * 1024 } fn default_pending_ttl_seconds() -> u64 { 60 * 60 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchedulerConfig { /// Whether the scheduler is enabled #[serde(default = "default_scheduler_enabled")] pub enabled: bool, /// Poll interval in seconds (how often to check for due jobs) #[serde(default = "default_poll_interval_secs")] pub poll_interval_secs: u64, /// Maximum concurrent job executions. #[serde(default = "default_max_concurrent")] pub max_concurrent: usize, /// Hard timeout for one scheduled execution. #[serde(default = "default_execution_timeout_secs")] pub execution_timeout_secs: u64, } fn default_scheduler_enabled() -> bool { true } fn default_poll_interval_secs() -> u64 { 60 } fn default_max_concurrent() -> usize { 1 } fn default_execution_timeout_secs() -> u64 { 900 } impl Default for SchedulerConfig { fn default() -> Self { Self { enabled: true, poll_interval_secs: 60, max_concurrent: 1, execution_timeout_secs: 900, } } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ClientConfig { #[serde(default = "default_gateway_url")] pub gateway_url: String, } fn default_gateway_host() -> String { "127.0.0.1".to_string() } fn default_gateway_port() -> u16 { 19876 } fn default_require_pairing() -> bool { true } fn default_gateway_url() -> String { "ws://127.0.0.1:19876/ws".to_string() } impl Default for ClientConfig { fn default() -> Self { Self { gateway_url: default_gateway_url(), } } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct MemoryConfig { /// Provider name for consolidation LLM calls (key in `providers`). /// If not set, falls back to the main agent's provider. #[serde(default)] pub consolidation_provider: Option, /// Model name for consolidation LLM calls (key in `models`). /// If not set, falls back to the main agent's model. #[serde(default)] pub consolidation_model: Option, /// Max knowledge entries injected into system prompt per turn. #[serde(default = "default_recall_limit")] pub recall_limit: usize, /// Idle minutes before triggering consolidation (for async channels). #[serde(default = "default_idle_consolidation_minutes")] pub idle_consolidation_minutes: u64, /// Days before timeline entries are auto-cleaned. #[serde(default = "default_timeline_retention_days")] pub timeline_retention_days: u64, /// Consecutive consolidation failures before degrading to raw archive. #[serde(default = "default_max_failures_before_degrade")] pub max_failures_before_degrade: usize, } impl Default for MemoryConfig { fn default() -> Self { Self { consolidation_provider: None, consolidation_model: None, recall_limit: 5, idle_consolidation_minutes: 10, timeline_retention_days: 90, max_failures_before_degrade: 3, } } } impl MemoryConfig { /// Resolve consolidation provider name, falling back to the main agent's provider. pub fn resolve_consolidation_provider(&self, default: &str) -> String { self.consolidation_provider .clone() .unwrap_or_else(|| default.to_string()) } /// Resolve consolidation model name, falling back to the main agent's model. pub fn resolve_consolidation_model(&self, default: &str) -> String { self.consolidation_model .clone() .unwrap_or_else(|| default.to_string()) } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct McpConfig { #[serde(default)] pub servers: Vec, #[serde(default = "default_mcp_tool_timeout_secs")] pub tool_timeout_secs: u64, } impl Default for McpConfig { fn default() -> Self { Self { servers: Vec::new(), tool_timeout_secs: default_mcp_tool_timeout_secs(), } } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct McpServerConfig { pub name: String, #[serde(default = "default_mcp_transport")] pub transport: McpTransport, #[serde(default)] pub command: Option, #[serde(default)] pub args: Vec, #[serde(default)] pub env: HashMap, #[serde(default)] pub url: Option, #[serde(default)] pub headers: HashMap, #[serde(default)] pub tool_timeout_secs: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum McpTransport { Stdio, Sse, #[serde(alias = "streamable-http")] StreamableHttp, } fn default_mcp_transport() -> McpTransport { McpTransport::Stdio } fn default_mcp_tool_timeout_secs() -> u64 { 180 } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct BrowserConfig { #[serde(default = "default_true")] pub enabled: bool, #[serde(default = "default_agent_browser_command")] pub command: String, #[serde(default = "default_true")] pub headless: bool, #[serde(default)] pub browser_executable_path: Option, #[serde(default = "default_browser_max_sessions")] pub max_sessions: usize, #[serde(default = "default_browser_idle_timeout_secs")] pub idle_timeout_secs: u64, #[serde(default = "default_browser_command_timeout_secs")] pub command_timeout_secs: u64, #[serde(default = "default_browser_max_output_chars")] pub max_output_chars: usize, #[serde(default = "default_true")] pub content_boundaries: bool, #[serde(default)] pub allowed_domains: Vec, #[serde(default)] pub allow_private_hosts: bool, #[serde(default = "default_browser_artifact_dir")] pub artifact_dir: String, #[serde(default)] pub persistence: BrowserPersistenceConfig, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct BrowserPersistenceConfig { #[serde(default = "default_browser_profile_dir")] pub profile_dir: String, } fn default_agent_browser_command() -> String { "agent-browser".to_string() } fn default_true() -> bool { true } fn default_browser_max_sessions() -> usize { 4 } fn default_browser_idle_timeout_secs() -> u64 { 15 * 60 } fn default_browser_command_timeout_secs() -> u64 { 120 } fn default_browser_max_output_chars() -> usize { 50_000 } fn default_browser_artifact_dir() -> String { get_user_config_dir() .join("media/browser") .to_string_lossy() .to_string() } fn default_browser_profile_dir() -> String { get_user_config_dir() .join("browser/profiles") .to_string_lossy() .to_string() } impl Default for BrowserPersistenceConfig { fn default() -> Self { Self { profile_dir: default_browser_profile_dir(), } } } impl Default for BrowserConfig { fn default() -> Self { Self { enabled: true, command: default_agent_browser_command(), headless: true, browser_executable_path: None, max_sessions: default_browser_max_sessions(), idle_timeout_secs: default_browser_idle_timeout_secs(), command_timeout_secs: default_browser_command_timeout_secs(), max_output_chars: default_browser_max_output_chars(), content_boundaries: true, allowed_domains: Vec::new(), allow_private_hosts: false, artifact_dir: default_browser_artifact_dir(), persistence: BrowserPersistenceConfig::default(), } } } fn default_recall_limit() -> usize { 5 } fn default_idle_consolidation_minutes() -> u64 { 10 } fn default_timeline_retention_days() -> u64 { 90 } fn default_max_failures_before_degrade() -> usize { 3 } #[derive(Debug, Clone)] pub struct LLMProviderConfig { pub provider_type: String, pub name: String, pub base_url: String, pub api_key: String, pub extra_headers: HashMap, pub model_id: String, pub temperature: Option, pub max_tokens: Option, pub model_extra: HashMap, pub max_tool_iterations: usize, pub token_limit: usize, pub workspace_dir: PathBuf, pub input_types: Vec, pub price_input_per_million: Option, pub price_output_per_million: Option, } impl LLMProviderConfig { pub fn cost_of(&self, prompt_tokens: u32, completion_tokens: u32) -> Option { match (self.price_input_per_million, self.price_output_per_million) { (Some(pi), Some(po)) => Some( prompt_tokens as f64 / 1e6 * pi + completion_tokens as f64 / 1e6 * po, ), _ => None, } } } pub fn get_default_config_path() -> PathBuf { get_user_config_dir().join("config.json") } /// Resolve the config file that `load_default` will read. This must be called /// before Gateway changes its working directory so the fallback remains stable. pub fn resolve_default_config_path() -> PathBuf { let primary = get_default_config_path(); if primary.exists() { primary } else { env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join("config.json") } } impl Config { pub fn load(path: &str) -> Result> { Self::load_from(Path::new(path)) } pub fn load_default() -> Result> { let path = resolve_default_config_path(); Self::load_from(&path) } pub(crate) fn load_from(path: &Path) -> Result> { let process_env = collect_process_env(); Self::load_from_with_process_env(path, &process_env, true, None) } /// Reload configuration without mutating the process environment. The /// supplied environment must be the process environment captured before /// startup `.env` layers were installed, preserving the documented /// precedence while keeping runtime reload thread-safe. pub(crate) fn load_for_reload( path: &Path, startup_process_env: &HashMap, startup_cwd: &Path, ) -> Result> { Self::load_from_with_process_env(path, startup_process_env, false, Some(startup_cwd)) } pub(crate) fn startup_process_env() -> HashMap { collect_process_env() } fn load_from_with_process_env( path: &Path, process_env: &HashMap, apply_to_process: bool, workspace_base: Option<&Path>, ) -> Result> { let config_path = if path.exists() { path.to_path_buf() } else { let fallback = env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join("config.json"); if fallback.exists() { fallback } else { return Err(Box::new(ConfigError::ConfigNotFound( path.to_string_lossy().to_string(), ))); } }; let content = fs::read_to_string(&config_path)?; let config_env_path = config_path .parent() .unwrap_or_else(|| Path::new(".")) .join(".env"); let config_env = read_env_file(&config_env_path)?; // The config-directory layer selects the workspace. Loading the workspace // layer first would be circular because its location comes from config.json. let initial_env = merge_env_layers(&config_env, &HashMap::new(), process_env); let initial_content = resolve_env_placeholders(&content, &initial_env); let initial_config: Config = serde_json::from_str(&initial_content)?; let mut workspace_path = expand_path(&initial_config.workspace_dir); if workspace_path.is_relative() && let Some(base) = workspace_base { workspace_path = base.join(workspace_path); } let workspace_env_path = workspace_path.join(".env"); let workspace_env = read_env_file(&workspace_env_path)?; let effective_env = merge_env_layers(&config_env, &workspace_env, process_env); let resolved_content = resolve_env_placeholders(&content, &effective_env); let config: Config = serde_json::from_str(&resolved_content)?; if config.workspace_dir != initial_config.workspace_dir { return Err(format!( "workspace .env cannot change workspace_dir (selected {}, resolved {})", initial_config.workspace_dir, config.workspace_dir ) .into()); } if apply_to_process { apply_env_layers(&config_env, &workspace_env, process_env); } tracing::info!( path = %config_path.display(), config_env = %config_env_path.display(), workspace_env = %workspace_env_path.display(), "Config and layered environment loaded" ); Ok(config) } pub fn get_provider_config(&self, agent_name: &str) -> Result { let agent = self .agents .get(agent_name) .ok_or(ConfigError::AgentNotFound(agent_name.to_string()))?; let provider = self .providers .get(&agent.provider) .ok_or(ConfigError::ProviderNotFound(agent.provider.clone()))?; let model = self .models .get(&agent.model) .ok_or(ConfigError::ModelNotFound(agent.model.clone()))?; Ok(LLMProviderConfig { provider_type: provider.provider_type.clone(), name: agent.provider.clone(), base_url: provider.base_url.clone(), api_key: provider.api_key.clone(), extra_headers: provider.extra_headers.clone(), model_id: model.model_id.clone(), temperature: model.temperature, max_tokens: model.max_tokens, model_extra: model.extra.clone(), max_tool_iterations: agent.max_tool_iterations, token_limit: agent.token_limit, workspace_dir: expand_path(&self.workspace_dir), input_types: model.input_type.clone(), price_input_per_million: None, price_output_per_million: None, }) } } #[derive(Debug)] pub enum ConfigError { ConfigNotFound(String), AgentNotFound(String), ProviderNotFound(String), ModelNotFound(String), } impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ConfigError::ConfigNotFound(path) => write!( f, "Config file not found: {}. Use CONFIG_PATH env var or place config in ~/.picobot/config.json", path ), ConfigError::AgentNotFound(name) => write!(f, "Agent not found: {}", name), ConfigError::ProviderNotFound(name) => write!(f, "Provider not found: {}", name), ConfigError::ModelNotFound(name) => write!(f, "Model not found: {}", name), } } } impl std::error::Error for ConfigError {} fn read_env_file(path: &Path) -> Result, Box> { let mut values = HashMap::new(); if !path.exists() { return Ok(values); } let content = fs::read_to_string(path)?; for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some((key, value)) = line.split_once('=') { let key = key.trim(); let value = value.trim().trim_matches('"').trim_matches('\''); if !key.is_empty() && !value.is_empty() { values.insert(key.to_string(), value.to_string()); } } } Ok(values) } fn collect_process_env() -> HashMap { env::vars_os() .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))) .collect() } fn merge_env_layers( config_env: &HashMap, workspace_env: &HashMap, process_env: &HashMap, ) -> HashMap { let mut merged = config_env.clone(); merged.extend(workspace_env.clone()); merged.extend(process_env.clone()); merged } fn apply_env_layers( config_env: &HashMap, workspace_env: &HashMap, process_env: &HashMap, ) { for (key, value) in config_env.iter().chain(workspace_env) { if !process_env.contains_key(key) { // SAFETY: Config loading happens during single-threaded startup before // Gateway background tasks are spawned. Existing process values are // never modified, and the workspace layer intentionally overwrites the // lower-priority config-directory layer. unsafe { env::set_var(key, value) }; } } } fn resolve_env_placeholders(content: &str, values: &HashMap) -> String { let re = Regex::new(r"<([A-Z_]+)>").expect("invalid regex"); re.replace_all(content, |caps: ®ex::Captures| { let var_name = &caps[1]; values .get(var_name) .cloned() .unwrap_or_else(|| caps[0].to_string()) }) .to_string() } #[cfg(test)] mod tests { use super::*; use std::ffi::OsString; use std::sync::Mutex; struct TestConfig { _dir: tempfile::TempDir, path: PathBuf, } impl TestConfig { fn path(&self) -> &Path { &self.path } } fn write_test_config() -> TestConfig { let dir = tempfile::TempDir::new().unwrap(); let path = dir.path().join("config.json"); let workspace = dir.path().join("workspace"); let mut content: serde_json::Value = serde_json::from_str( r#"{ "providers": { "aliyun": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": "test-key", "extra_headers": {} }, "volcengine": { "type": "openai", "base_url": "https://example.invalid/volc", "api_key": "test-key-2", "extra_headers": {} } }, "models": { "qwen-plus": { "model_id": "qwen-plus", "temperature": 0.0 }, "doubao-seed-2-0-lite-260215": { "model_id": "doubao-seed-2-0-lite-260215" } }, "agents": { "default": { "provider": "aliyun", "model": "qwen-plus" } }, "gateway": { "host": "0.0.0.0", "port": 19876 } }"#, ) .unwrap(); content["workspace_dir"] = serde_json::json!(workspace); std::fs::write(&path, serde_json::to_vec_pretty(&content).unwrap()).unwrap(); TestConfig { _dir: dir, path } } static ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); struct EnvRestore(Vec<(&'static str, Option)>); impl Drop for EnvRestore { fn drop(&mut self) { for (key, value) in self.0.drain(..) { if let Some(value) = value { // SAFETY: The test serializes mutations of these unique keys. unsafe { env::set_var(key, value) }; } else { // SAFETY: The test serializes mutations of these unique keys. unsafe { env::remove_var(key) }; } } } } #[test] fn layered_env_uses_config_then_workspace_then_process_precedence() { const VALUE: &str = "PICOBOT_ENV_LAYERING_VALUE"; const SYSTEM: &str = "PICOBOT_ENV_LAYERING_SYSTEM"; const CONFIG_ONLY: &str = "PICOBOT_ENV_LAYERING_CONFIG_ONLY"; let _lock = ENV_TEST_LOCK.lock().unwrap(); let _restore = EnvRestore( [VALUE, SYSTEM, CONFIG_ONLY] .into_iter() .map(|key| (key, env::var_os(key))) .collect(), ); // SAFETY: Config loading is the code under test and these unique keys are // protected by ENV_TEST_LOCK for the duration of the test. unsafe { env::remove_var(VALUE); env::set_var(SYSTEM, "process"); env::remove_var(CONFIG_ONLY); } let dir = tempfile::TempDir::new().unwrap(); let config_dir = dir.path().join("config"); let workspace_dir = dir.path().join("workspace"); fs::create_dir_all(&config_dir).unwrap(); fs::create_dir_all(&workspace_dir).unwrap(); fs::write( config_dir.join(".env"), format!("{VALUE}=config\n{SYSTEM}=config\n{CONFIG_ONLY}=config-only\n"), ) .unwrap(); fs::write( workspace_dir.join(".env"), format!("{VALUE}=workspace\n{SYSTEM}=workspace\n"), ) .unwrap(); let config_path = config_dir.join("config.json"); let config_json = serde_json::json!({ "providers": { "default": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": format!("<{VALUE}>|<{SYSTEM}>|<{CONFIG_ONLY}>") } }, "models": { "default": { "model_id": "test" } }, "agents": { "default": { "provider": "default", "model": "default" } }, "workspace_dir": workspace_dir }); fs::write( &config_path, serde_json::to_vec_pretty(&config_json).unwrap(), ) .unwrap(); let config = Config::load(config_path.to_str().unwrap()).unwrap(); assert_eq!( config.providers["default"].api_key, "workspace|process|config-only" ); assert_eq!(env::var(VALUE).unwrap(), "workspace"); assert_eq!(env::var(SYSTEM).unwrap(), "process"); assert_eq!(env::var(CONFIG_ONLY).unwrap(), "config-only"); } #[test] fn test_config_load() { let file = write_test_config(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); // Check providers assert!(config.providers.contains_key("volcengine")); assert!(config.providers.contains_key("aliyun")); // Check models assert!(config.models.contains_key("doubao-seed-2-0-lite-260215")); assert!(config.models.contains_key("qwen-plus")); // Check agents assert!(config.agents.contains_key("default")); } #[test] fn test_get_provider_config() { let file = write_test_config(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); let provider_config = config.get_provider_config("default").unwrap(); assert_eq!(provider_config.provider_type, "openai"); assert_eq!(provider_config.name, "aliyun"); assert_eq!(provider_config.model_id, "qwen-plus"); assert_eq!(provider_config.temperature, Some(0.0)); } #[test] fn test_default_gateway_config() { let file = write_test_config(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); assert_eq!(config.gateway.host, "0.0.0.0"); assert_eq!(config.gateway.port, 19876); assert!(config.gateway.require_pairing); assert!(config.gateway.file_transfer.enabled); assert_eq!( config.gateway.file_transfer.max_file_bytes, 25 * 1024 * 1024 ); assert!(config.browser.enabled); let browser: BrowserConfig = serde_json::from_str("{}").unwrap(); assert!(browser.enabled); assert!( browser .persistence .profile_dir .ends_with("browser/profiles") ); } #[test] fn browser_persistence_config_is_strict_and_explicit() { let browser: BrowserConfig = serde_json::from_str( r#"{ "persistence": { "profile_dir": "/tmp/picobot-browser-profiles" } }"#, ) .unwrap(); assert_eq!( browser.persistence.profile_dir, "/tmp/picobot-browser-profiles" ); assert!( serde_json::from_str::( r#"{"persistence":{"profile_dir":"/tmp/profiles","unknown":1}}"# ) .is_err() ); } #[test] fn default_config_path_is_stable_across_working_directory_changes() { assert!(resolve_default_config_path().is_absolute()); } }