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