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