browsing 0.1.3

Lightweight MCP/API for browser automation: navigate, get content (text), screenshot. Parallelism via RwLock.
Documentation
//! Configuration management for browsing-rs

pub use crate::browser::profile::{BrowserProfile, ProxyConfig};
use crate::error::BrowsingError;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::warn;

/// Configuration for LLM
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
    /// API key for the LLM service
    pub api_key: Option<String>,
    /// Model name to use
    pub model: Option<String>,
    /// Temperature for generation
    pub temperature: Option<f64>,
    /// Maximum number of tokens to generate
    pub max_tokens: Option<u32>,
}

impl Default for LlmConfig {
    fn default() -> Self {
        Self {
            api_key: None,
            model: None,
            temperature: None,
            max_tokens: None,
        }
    }
}

/// Configuration for the agent
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
    /// Maximum number of steps
    pub max_steps: Option<u32>,
    /// Whether to use vision
    pub use_vision: Option<bool>,
    /// System prompt override
    pub system_prompt: Option<String>,
}

impl Default for AgentConfig {
    fn default() -> Self {
        Self {
            max_steps: Some(100),
            use_vision: None,
            system_prompt: None,
        }
    }
}

/// Main configuration structure (streamlined)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    /// Browser profile configuration (unified)
    pub browser_profile: BrowserProfile,
    /// LLM configuration
    pub llm: LlmConfig,
    /// Agent configuration
    pub agent: AgentConfig,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            browser_profile: BrowserProfile::default(),
            llm: LlmConfig::default(),
            agent: AgentConfig::default(),
        }
    }
}

impl Config {
    /// Validate the configuration and return detailed errors if any
    pub fn validate(&self) -> Result<(), Vec<String>> {
        let mut errors = Vec::new();

        // Validate LLM configuration
        if let Some(ref api_key) = self.llm.api_key {
            if api_key.is_empty() {
                errors.push("LLM API key is empty".to_string());
            } else if api_key.len() < 10 {
                errors.push(format!(
                    "LLM API key is too short ({} characters, expected at least 10)",
                    api_key.len()
                ));
            }
        }

        if let Some(ref model) = self.llm.model {
            if model.is_empty() {
                errors.push("LLM model name is empty".to_string());
            }
        }

        if let Some(temperature) = self.llm.temperature {
            if temperature < 0.0 || temperature > 2.0 {
                errors.push(format!(
                    "LLM temperature out of range ({}), must be between 0.0 and 2.0",
                    temperature
                ));
            }
        }

        if let Some(max_tokens) = self.llm.max_tokens {
            if max_tokens == 0 {
                errors.push("LLM max_tokens cannot be zero".to_string());
            } else if max_tokens > 128000 {
                errors.push(format!(
                    "LLM max_tokens too large ({}), maximum is 128000",
                    max_tokens
                ));
            }
        }

        // Validate Agent configuration
        if let Some(max_steps) = self.agent.max_steps {
            if max_steps == 0 {
                errors.push("Agent max_steps cannot be zero".to_string());
            } else if max_steps > 1000 {
                errors.push(format!(
                    "Agent max_steps too large ({}), maximum is 1000",
                    max_steps
                ));
            }
        }

        // Validate Browser profile
        if let Some(ref allowed_domains) = self.browser_profile.allowed_domains {
            if allowed_domains.is_empty() {
                errors.push(
                    "Browser allowed_domains list is empty, which would block all domains"
                        .to_string(),
                );
            } else {
                for domain in allowed_domains {
                    if domain.is_empty() {
                        errors.push("Browser allowed_domains contains empty domain".to_string());
                    } else if !domain.contains('.') && !domain.is_empty() {
                        errors.push(format!(
                            "Invalid domain format: '{}', domains should contain at least one dot",
                            domain
                        ));
                    }
                }
            }
        }

        // Validate proxy configuration
        if let Some(ref proxy) = self.browser_profile.proxy {
            if proxy.server.is_empty() {
                errors.push("Proxy server URL is empty".to_string());
            } else if !proxy.server.starts_with("http://")
                && !proxy.server.starts_with("https://")
                && !proxy.server.starts_with("socks://")
            {
                errors.push(format!(
                    "Invalid proxy URL scheme: '{}', must be http://, https://, or socks://",
                    proxy.server
                ));
            }

            if let Some(ref bypass) = proxy.bypass {
                if bypass.is_empty() {
                    errors.push("Proxy bypass list is empty".to_string());
                }
            }
        }

        if errors.is_empty() {
            Ok(())
        } else {
            Err(errors)
        }
    }

    /// Validate and return an error if invalid
    pub fn validate_or_error(&self) -> Result<(), BrowsingError> {
        match self.validate() {
            Ok(()) => Ok(()),
            Err(errors) => {
                let error_msg = if errors.len() == 1 {
                    format!("Configuration error: {}", errors[0])
                } else {
                    format!(
                        "Configuration errors ({}):\n  {}",
                        errors.len(),
                        errors.join("\n  ")
                    )
                };
                Err(BrowsingError::Config(error_msg))
            }
        }
    }
}

/// Parse proxy configuration from environment variables (public for testing)
pub fn parse_proxy_config_from_env() -> Option<ProxyConfig> {
    let server = std::env::var("HTTP_PROXY")
        .or_else(|_| std::env::var("http_proxy"))
        .or_else(|_| std::env::var("HTTPS_PROXY"))
        .or_else(|_| std::env::var("https_proxy"))
        .ok()?;

    let bypass = std::env::var("NO_PROXY")
        .or_else(|_| std::env::var("no_proxy"))
        .ok()
        .filter(|s| !s.is_empty());

    let username = std::env::var("PROXY_USERNAME")
        .or_else(|_| std::env::var("proxy_username"))
        .ok()
        .filter(|s| !s.is_empty());

    let password = std::env::var("PROXY_PASSWORD")
        .or_else(|_| std::env::var("proxy_password"))
        .ok()
        .filter(|s| !s.is_empty());

    Some(ProxyConfig {
        server,
        bypass,
        username,
        password,
    })
}

impl Config {
    /// Creates a Config from environment variables
    pub fn from_env() -> Self {
        // Load .env file if present
        let _ = dotenv::dotenv();

        Self {
            browser_profile: BrowserProfile {
                headless: std::env::var("BROWSER_USE_HEADLESS")
                    .ok()
                    .and_then(|v| v.parse().ok()),
                user_data_dir: std::env::var("BROWSER_USE_USER_DATA_DIR")
                    .ok()
                    .map(PathBuf::from),
                allowed_domains: std::env::var("BROWSER_USE_ALLOWED_DOMAINS")
                    .ok()
                    .map(|s| s.split(',').map(|s| s.trim().to_string()).collect()),
                downloads_path: std::env::var("BROWSER_USE_DOWNLOADS_PATH")
                    .ok()
                    .map(PathBuf::from),
                proxy: parse_proxy_config_from_env(),
            },
            llm: LlmConfig {
                api_key: std::env::var("LLM_API_KEY").ok(),
                model: std::env::var("LLM_MODEL").ok(),
                temperature: std::env::var("LLM_TEMPERATURE")
                    .ok()
                    .and_then(|v| v.parse().ok()),
                max_tokens: std::env::var("LLM_MAX_TOKENS")
                    .ok()
                    .and_then(|v| v.parse().ok()),
            },
            agent: AgentConfig {
                max_steps: std::env::var("BROWSER_USE_MAX_STEPS")
                    .ok()
                    .and_then(|v| v.parse().ok())
                    .or(Some(100)),
                use_vision: std::env::var("BROWSER_USE_VISION")
                    .ok()
                    .and_then(|v| v.parse().ok()),
                system_prompt: None,
            },
        }
    }

    /// Loads configuration from a file
    pub fn load_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
        if !path.as_ref().exists() {
            warn!("Config file not found, using defaults");
            return Ok(Self::from_env());
        }

        let content = std::fs::read_to_string(path)?;
        let config: Config = serde_json::from_str(&content)?;
        Ok(config)
    }
}