swink-agent-plugin-web 0.12.0

Web browsing plugin for swink-agent: fetch, search, screenshot, extract
Documentation
use std::fmt;
use std::path::PathBuf;
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// Which search backend to use.
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub enum SearchProviderKind {
    #[default]
    DuckDuckGo,
    Brave,
    Tavily,
}

/// Configuration for the web plugin.
#[non_exhaustive]
#[derive(Clone)]
pub struct WebPluginConfig {
    pub search_provider_kind: SearchProviderKind,
    pub brave_api_key: Option<String>,
    pub tavily_api_key: Option<String>,
    pub domain_allowlist: Vec<String>,
    pub domain_denylist: Vec<String>,
    pub block_private_ips: bool,
    pub rate_limit_rpm: u32,
    pub max_content_length: usize,
    pub max_redirects: u32,
    pub max_search_results: usize,
    pub playwright_path: Option<PathBuf>,
    pub screenshot_timeout: Duration,
    pub request_timeout: Duration,
    pub viewport_width: u32,
    pub viewport_height: u32,
    pub sanitizer_enabled: bool,
    pub user_agent: String,
}

impl fmt::Debug for WebPluginConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("WebPluginConfig")
            .field("search_provider_kind", &self.search_provider_kind)
            .field(
                "brave_api_key",
                &self.brave_api_key.as_ref().map(|_| "[REDACTED]"),
            )
            .field(
                "tavily_api_key",
                &self.tavily_api_key.as_ref().map(|_| "[REDACTED]"),
            )
            .field("domain_allowlist", &self.domain_allowlist)
            .field("domain_denylist", &self.domain_denylist)
            .field("block_private_ips", &self.block_private_ips)
            .field("rate_limit_rpm", &self.rate_limit_rpm)
            .field("max_content_length", &self.max_content_length)
            .field("max_redirects", &self.max_redirects)
            .field("max_search_results", &self.max_search_results)
            .field("playwright_path", &self.playwright_path)
            .field("screenshot_timeout", &self.screenshot_timeout)
            .field("request_timeout", &self.request_timeout)
            .field("viewport_width", &self.viewport_width)
            .field("viewport_height", &self.viewport_height)
            .field("sanitizer_enabled", &self.sanitizer_enabled)
            .field("user_agent", &self.user_agent)
            .finish()
    }
}

impl Default for WebPluginConfig {
    fn default() -> Self {
        Self {
            search_provider_kind: SearchProviderKind::default(),
            brave_api_key: None,
            tavily_api_key: None,
            domain_allowlist: Vec::new(),
            domain_denylist: Vec::new(),
            block_private_ips: true,
            rate_limit_rpm: 30,
            max_content_length: 50_000,
            max_redirects: 10,
            max_search_results: 10,
            playwright_path: None,
            screenshot_timeout: Duration::from_secs(15),
            request_timeout: Duration::from_secs(30),
            viewport_width: 1280,
            viewport_height: 720,
            sanitizer_enabled: true,
            user_agent: String::from("SwinkAgent/0.5"),
        }
    }
}

/// Builder for `WebPluginConfig`.
#[derive(Debug, Default)]
pub struct WebPluginConfigBuilder {
    config: WebPluginConfig,
}

impl WebPluginConfigBuilder {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    #[must_use]
    pub fn with_search_provider(mut self, kind: SearchProviderKind) -> Self {
        self.config.search_provider_kind = kind;
        self
    }

    #[must_use]
    pub fn with_brave_api_key(mut self, key: impl Into<String>) -> Self {
        self.config.brave_api_key = Some(key.into());
        self
    }

    #[must_use]
    pub fn with_tavily_api_key(mut self, key: impl Into<String>) -> Self {
        self.config.tavily_api_key = Some(key.into());
        self
    }

    #[must_use]
    pub fn with_domain_allowlist(mut self, domains: Vec<String>) -> Self {
        self.config.domain_allowlist = domains;
        self
    }

    #[must_use]
    pub fn with_domain_denylist(mut self, domains: Vec<String>) -> Self {
        self.config.domain_denylist = domains;
        self
    }

    #[must_use]
    pub fn with_block_private_ips(mut self, block: bool) -> Self {
        self.config.block_private_ips = block;
        self
    }

    #[must_use]
    pub fn with_rate_limit_rpm(mut self, rpm: u32) -> Self {
        self.config.rate_limit_rpm = rpm;
        self
    }

    #[must_use]
    pub fn with_max_content_length(mut self, length: usize) -> Self {
        self.config.max_content_length = length;
        self
    }

    #[must_use]
    pub fn with_max_redirects(mut self, max: u32) -> Self {
        self.config.max_redirects = max;
        self
    }

    #[must_use]
    pub fn with_max_search_results(mut self, max: usize) -> Self {
        self.config.max_search_results = max;
        self
    }

    #[must_use]
    pub fn with_playwright_path(mut self, path: PathBuf) -> Self {
        self.config.playwright_path = Some(path);
        self
    }

    #[must_use]
    pub fn with_screenshot_timeout(mut self, timeout: Duration) -> Self {
        self.config.screenshot_timeout = timeout;
        self
    }

    #[must_use]
    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
        self.config.request_timeout = timeout;
        self
    }

    #[must_use]
    pub fn with_viewport(mut self, width: u32, height: u32) -> Self {
        self.config.viewport_width = width;
        self.config.viewport_height = height;
        self
    }

    #[must_use]
    pub fn with_sanitizer_enabled(mut self, enabled: bool) -> Self {
        self.config.sanitizer_enabled = enabled;
        self
    }

    #[must_use]
    pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
        self.config.user_agent = ua.into();
        self
    }

    #[must_use]
    pub fn build(self) -> WebPluginConfig {
        self.config
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_config_has_expected_values() {
        let config = WebPluginConfigBuilder::new().build();
        assert!(matches!(
            config.search_provider_kind,
            SearchProviderKind::DuckDuckGo
        ));
        assert!(config.block_private_ips);
        assert_eq!(config.rate_limit_rpm, 30);
        assert_eq!(config.max_content_length, 50_000);
        assert_eq!(config.max_redirects, 10);
        assert_eq!(config.max_search_results, 10);
        assert!(config.sanitizer_enabled);
        assert_eq!(config.user_agent, "SwinkAgent/0.5");
        assert_eq!(config.viewport_width, 1280);
        assert_eq!(config.viewport_height, 720);
        assert!(config.domain_allowlist.is_empty());
        assert!(config.domain_denylist.is_empty());
        assert!(config.brave_api_key.is_none());
        assert!(config.tavily_api_key.is_none());
        assert!(config.playwright_path.is_none());
        assert_eq!(config.screenshot_timeout, Duration::from_secs(15));
        assert_eq!(config.request_timeout, Duration::from_secs(30));
    }

    #[test]
    fn builder_chaining_applies_overrides() {
        let config = WebPluginConfigBuilder::new()
            .with_search_provider(SearchProviderKind::Brave)
            .with_brave_api_key("test-key")
            .with_rate_limit_rpm(60)
            .with_max_content_length(100_000)
            .with_block_private_ips(false)
            .with_user_agent("TestAgent/1.0")
            .with_viewport(1920, 1080)
            .with_max_redirects(3)
            .with_max_search_results(25)
            .with_sanitizer_enabled(false)
            .with_request_timeout(Duration::from_mins(1))
            .with_screenshot_timeout(Duration::from_secs(30))
            .with_domain_allowlist(vec!["example.com".into()])
            .with_domain_denylist(vec!["evil.com".into()])
            .build();

        assert!(matches!(
            config.search_provider_kind,
            SearchProviderKind::Brave
        ));
        assert_eq!(config.brave_api_key.as_deref(), Some("test-key"));
        assert_eq!(config.rate_limit_rpm, 60);
        assert_eq!(config.max_content_length, 100_000);
        assert!(!config.block_private_ips);
        assert_eq!(config.user_agent, "TestAgent/1.0");
        assert_eq!(config.viewport_width, 1920);
        assert_eq!(config.viewport_height, 1080);
        assert_eq!(config.max_redirects, 3);
        assert_eq!(config.max_search_results, 25);
        assert!(!config.sanitizer_enabled);
        assert_eq!(config.request_timeout, Duration::from_mins(1));
        assert_eq!(config.screenshot_timeout, Duration::from_secs(30));
        assert_eq!(config.domain_allowlist, vec!["example.com"]);
        assert_eq!(config.domain_denylist, vec!["evil.com"]);
    }

    #[test]
    fn debug_output_redacts_search_provider_api_keys() {
        const BRAVE_SECRET: &str = "brave-super-secret-token";
        const TAVILY_SECRET: &str = "tavily-super-secret-token";
        let config = WebPluginConfigBuilder::new()
            .with_brave_api_key(BRAVE_SECRET)
            .with_tavily_api_key(TAVILY_SECRET)
            .build();

        let debug = format!("{config:?}");

        assert!(!debug.contains(BRAVE_SECRET), "brave key leaked: {debug}");
        assert!(!debug.contains(TAVILY_SECRET), "tavily key leaked: {debug}");
        assert_eq!(debug.matches("[REDACTED]").count(), 2, "{debug}");
    }
}