1use std::fmt;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub enum SearchProviderKind {
10 #[default]
11 DuckDuckGo,
12 Brave,
13 Tavily,
14}
15
16#[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#[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}