Skip to main content

swink_agent_plugin_web/
config.rs

1use std::fmt;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7/// Which search backend to use.
8#[non_exhaustive]
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub enum SearchProviderKind {
11    #[default]
12    DuckDuckGo,
13    Brave,
14    Tavily,
15}
16
17/// Configuration for the web plugin.
18#[non_exhaustive]
19#[derive(Clone)]
20pub struct WebPluginConfig {
21    pub search_provider_kind: SearchProviderKind,
22    pub brave_api_key: Option<String>,
23    pub tavily_api_key: Option<String>,
24    pub domain_allowlist: Vec<String>,
25    pub domain_denylist: Vec<String>,
26    pub block_private_ips: bool,
27    pub rate_limit_rpm: u32,
28    pub max_content_length: usize,
29    pub max_redirects: u32,
30    pub max_search_results: usize,
31    pub playwright_path: Option<PathBuf>,
32    pub screenshot_timeout: Duration,
33    pub request_timeout: Duration,
34    pub viewport_width: u32,
35    pub viewport_height: u32,
36    pub sanitizer_enabled: bool,
37    pub user_agent: String,
38}
39
40impl fmt::Debug for WebPluginConfig {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.debug_struct("WebPluginConfig")
43            .field("search_provider_kind", &self.search_provider_kind)
44            .field(
45                "brave_api_key",
46                &self.brave_api_key.as_ref().map(|_| "[REDACTED]"),
47            )
48            .field(
49                "tavily_api_key",
50                &self.tavily_api_key.as_ref().map(|_| "[REDACTED]"),
51            )
52            .field("domain_allowlist", &self.domain_allowlist)
53            .field("domain_denylist", &self.domain_denylist)
54            .field("block_private_ips", &self.block_private_ips)
55            .field("rate_limit_rpm", &self.rate_limit_rpm)
56            .field("max_content_length", &self.max_content_length)
57            .field("max_redirects", &self.max_redirects)
58            .field("max_search_results", &self.max_search_results)
59            .field("playwright_path", &self.playwright_path)
60            .field("screenshot_timeout", &self.screenshot_timeout)
61            .field("request_timeout", &self.request_timeout)
62            .field("viewport_width", &self.viewport_width)
63            .field("viewport_height", &self.viewport_height)
64            .field("sanitizer_enabled", &self.sanitizer_enabled)
65            .field("user_agent", &self.user_agent)
66            .finish()
67    }
68}
69
70impl Default for WebPluginConfig {
71    fn default() -> Self {
72        Self {
73            search_provider_kind: SearchProviderKind::default(),
74            brave_api_key: None,
75            tavily_api_key: None,
76            domain_allowlist: Vec::new(),
77            domain_denylist: Vec::new(),
78            block_private_ips: true,
79            rate_limit_rpm: 30,
80            max_content_length: 50_000,
81            max_redirects: 10,
82            max_search_results: 10,
83            playwright_path: None,
84            screenshot_timeout: Duration::from_secs(15),
85            request_timeout: Duration::from_secs(30),
86            viewport_width: 1280,
87            viewport_height: 720,
88            sanitizer_enabled: true,
89            user_agent: String::from("SwinkAgent/0.5"),
90        }
91    }
92}
93
94/// Builder for `WebPluginConfig`.
95#[derive(Debug, Default)]
96pub struct WebPluginConfigBuilder {
97    config: WebPluginConfig,
98}
99
100impl WebPluginConfigBuilder {
101    #[must_use]
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    #[must_use]
107    pub fn with_search_provider(mut self, kind: SearchProviderKind) -> Self {
108        self.config.search_provider_kind = kind;
109        self
110    }
111
112    #[must_use]
113    pub fn with_brave_api_key(mut self, key: impl Into<String>) -> Self {
114        self.config.brave_api_key = Some(key.into());
115        self
116    }
117
118    #[must_use]
119    pub fn with_tavily_api_key(mut self, key: impl Into<String>) -> Self {
120        self.config.tavily_api_key = Some(key.into());
121        self
122    }
123
124    #[must_use]
125    pub fn with_domain_allowlist(mut self, domains: Vec<String>) -> Self {
126        self.config.domain_allowlist = domains;
127        self
128    }
129
130    #[must_use]
131    pub fn with_domain_denylist(mut self, domains: Vec<String>) -> Self {
132        self.config.domain_denylist = domains;
133        self
134    }
135
136    #[must_use]
137    pub fn with_block_private_ips(mut self, block: bool) -> Self {
138        self.config.block_private_ips = block;
139        self
140    }
141
142    #[must_use]
143    pub fn with_rate_limit_rpm(mut self, rpm: u32) -> Self {
144        self.config.rate_limit_rpm = rpm;
145        self
146    }
147
148    #[must_use]
149    pub fn with_max_content_length(mut self, length: usize) -> Self {
150        self.config.max_content_length = length;
151        self
152    }
153
154    #[must_use]
155    pub fn with_max_redirects(mut self, max: u32) -> Self {
156        self.config.max_redirects = max;
157        self
158    }
159
160    #[must_use]
161    pub fn with_max_search_results(mut self, max: usize) -> Self {
162        self.config.max_search_results = max;
163        self
164    }
165
166    #[must_use]
167    pub fn with_playwright_path(mut self, path: PathBuf) -> Self {
168        self.config.playwright_path = Some(path);
169        self
170    }
171
172    #[must_use]
173    pub fn with_screenshot_timeout(mut self, timeout: Duration) -> Self {
174        self.config.screenshot_timeout = timeout;
175        self
176    }
177
178    #[must_use]
179    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
180        self.config.request_timeout = timeout;
181        self
182    }
183
184    #[must_use]
185    pub fn with_viewport(mut self, width: u32, height: u32) -> Self {
186        self.config.viewport_width = width;
187        self.config.viewport_height = height;
188        self
189    }
190
191    #[must_use]
192    pub fn with_sanitizer_enabled(mut self, enabled: bool) -> Self {
193        self.config.sanitizer_enabled = enabled;
194        self
195    }
196
197    #[must_use]
198    pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
199        self.config.user_agent = ua.into();
200        self
201    }
202
203    #[must_use]
204    pub fn build(self) -> WebPluginConfig {
205        self.config
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn default_config_has_expected_values() {
215        let config = WebPluginConfigBuilder::new().build();
216        assert!(matches!(
217            config.search_provider_kind,
218            SearchProviderKind::DuckDuckGo
219        ));
220        assert!(config.block_private_ips);
221        assert_eq!(config.rate_limit_rpm, 30);
222        assert_eq!(config.max_content_length, 50_000);
223        assert_eq!(config.max_redirects, 10);
224        assert_eq!(config.max_search_results, 10);
225        assert!(config.sanitizer_enabled);
226        assert_eq!(config.user_agent, "SwinkAgent/0.5");
227        assert_eq!(config.viewport_width, 1280);
228        assert_eq!(config.viewport_height, 720);
229        assert!(config.domain_allowlist.is_empty());
230        assert!(config.domain_denylist.is_empty());
231        assert!(config.brave_api_key.is_none());
232        assert!(config.tavily_api_key.is_none());
233        assert!(config.playwright_path.is_none());
234        assert_eq!(config.screenshot_timeout, Duration::from_secs(15));
235        assert_eq!(config.request_timeout, Duration::from_secs(30));
236    }
237
238    #[test]
239    fn builder_chaining_applies_overrides() {
240        let config = WebPluginConfigBuilder::new()
241            .with_search_provider(SearchProviderKind::Brave)
242            .with_brave_api_key("test-key")
243            .with_rate_limit_rpm(60)
244            .with_max_content_length(100_000)
245            .with_block_private_ips(false)
246            .with_user_agent("TestAgent/1.0")
247            .with_viewport(1920, 1080)
248            .with_max_redirects(3)
249            .with_max_search_results(25)
250            .with_sanitizer_enabled(false)
251            .with_request_timeout(Duration::from_mins(1))
252            .with_screenshot_timeout(Duration::from_secs(30))
253            .with_domain_allowlist(vec!["example.com".into()])
254            .with_domain_denylist(vec!["evil.com".into()])
255            .build();
256
257        assert!(matches!(
258            config.search_provider_kind,
259            SearchProviderKind::Brave
260        ));
261        assert_eq!(config.brave_api_key.as_deref(), Some("test-key"));
262        assert_eq!(config.rate_limit_rpm, 60);
263        assert_eq!(config.max_content_length, 100_000);
264        assert!(!config.block_private_ips);
265        assert_eq!(config.user_agent, "TestAgent/1.0");
266        assert_eq!(config.viewport_width, 1920);
267        assert_eq!(config.viewport_height, 1080);
268        assert_eq!(config.max_redirects, 3);
269        assert_eq!(config.max_search_results, 25);
270        assert!(!config.sanitizer_enabled);
271        assert_eq!(config.request_timeout, Duration::from_mins(1));
272        assert_eq!(config.screenshot_timeout, Duration::from_secs(30));
273        assert_eq!(config.domain_allowlist, vec!["example.com"]);
274        assert_eq!(config.domain_denylist, vec!["evil.com"]);
275    }
276
277    #[test]
278    fn debug_output_redacts_search_provider_api_keys() {
279        const BRAVE_SECRET: &str = "brave-super-secret-token";
280        const TAVILY_SECRET: &str = "tavily-super-secret-token";
281        let config = WebPluginConfigBuilder::new()
282            .with_brave_api_key(BRAVE_SECRET)
283            .with_tavily_api_key(TAVILY_SECRET)
284            .build();
285
286        let debug = format!("{config:?}");
287
288        assert!(!debug.contains(BRAVE_SECRET), "brave key leaked: {debug}");
289        assert!(!debug.contains(TAVILY_SECRET), "tavily key leaked: {debug}");
290        assert_eq!(debug.matches("[REDACTED]").count(), 2, "{debug}");
291    }
292}