1use std::path::PathBuf;
2use std::time::Duration;
3
4use crate::error::BrowserError;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum LaunchMode {
9 Auto,
11 LaunchNew,
13 AttachOnly,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ProfileMode {
20 Ephemeral,
22 Persistent(PathBuf),
24 UserDefault,
26}
27
28#[derive(Debug, Clone)]
30#[allow(clippy::struct_excessive_bools)]
33pub struct BrowserConfig {
34 pub(crate) mode: LaunchMode,
35 pub(crate) host: String,
36 pub(crate) port: u16,
37 pub(crate) headless: bool,
38 pub(crate) enable_automation: bool,
39 pub(crate) profile: ProfileMode,
40 pub(crate) proxy: Option<String>,
41 pub(crate) chrome_path: Option<PathBuf>,
42 pub(crate) extra_args: Vec<String>,
43 pub(crate) window_size: Option<(u32, u32)>,
44 pub(crate) user_agent: Option<String>,
45 pub(crate) connect_timeout: Duration,
46 pub(crate) startup_timeout: Duration,
47 pub(crate) command_timeout: Duration,
48 pub(crate) keep_alive_on_drop: bool,
49 pub(crate) auto_relaunch: bool,
50 pub(crate) no_sandbox: Option<bool>,
51}
52
53impl BrowserConfig {
54 pub fn builder() -> BrowserConfigBuilder {
55 BrowserConfigBuilder::default()
56 }
57
58 pub fn port(&self) -> u16 {
59 self.port
60 }
61
62 pub fn validate(&self) -> Result<(), BrowserError> {
63 match self.mode {
64 LaunchMode::AttachOnly => {
65 if self.port == 0 {
66 return Err(BrowserError::InvalidConfig(
67 "AttachOnly requires an explicit port; port 0 (ephemeral) is not \
68 allowed because the mode must connect to a known endpoint"
69 .to_string(),
70 ));
71 }
72 }
73 LaunchMode::Auto => {
74 if self.port == 0 {
75 return Err(BrowserError::InvalidConfig(
76 "Auto requires an explicit port; port 0 (ephemeral) is not allowed \
77 because Auto means 'attach to that port or launch on it'"
78 .to_string(),
79 ));
80 }
81 }
82 LaunchMode::LaunchNew => {}
83 }
84
85 if matches!(self.mode, LaunchMode::Auto | LaunchMode::LaunchNew)
86 && !is_local_host(&self.host)
87 {
88 let mode_name = match self.mode {
89 LaunchMode::Auto => "Auto",
90 LaunchMode::LaunchNew => "LaunchNew",
91 LaunchMode::AttachOnly => unreachable!("guarded by the matches! above"),
92 };
93 return Err(BrowserError::InvalidConfig(format!(
94 "{mode_name} cannot be used with a remote host '{host}'; \
95 only AttachOnly supports remote hosts",
96 host = self.host
97 )));
98 }
99
100 Ok(())
101 }
102}
103
104#[derive(Debug, Clone)]
105#[allow(clippy::struct_excessive_bools)]
107pub struct BrowserConfigBuilder {
108 mode: LaunchMode,
109 host: String,
110 port: u16,
111 headless: bool,
112 enable_automation: bool,
113 profile: ProfileMode,
114 proxy: Option<String>,
115 chrome_path: Option<PathBuf>,
116 extra_args: Vec<String>,
117 window_size: Option<(u32, u32)>,
118 user_agent: Option<String>,
119 connect_timeout: Duration,
120 startup_timeout: Duration,
121 command_timeout: Duration,
122 keep_alive_on_drop: bool,
123 auto_relaunch: bool,
124 no_sandbox: Option<bool>,
125}
126
127impl BrowserConfigBuilder {
128 pub fn mode(mut self, mode: LaunchMode) -> Self {
129 self.mode = mode;
130 self
131 }
132
133 pub fn host(mut self, host: impl Into<String>) -> Self {
134 self.host = host.into();
135 self
136 }
137
138 pub fn port(mut self, port: u16) -> Self {
139 self.port = port;
140 self
141 }
142
143 pub fn headless(mut self, v: bool) -> Self {
144 self.headless = v;
145 self
146 }
147
148 pub fn enable_automation(mut self, v: bool) -> Self {
149 self.enable_automation = v;
150 self
151 }
152
153 pub fn profile(mut self, mode: ProfileMode) -> Self {
154 self.profile = mode;
155 self
156 }
157
158 pub fn proxy(mut self, url: impl Into<String>) -> Self {
159 self.proxy = Some(url.into());
160 self
161 }
162
163 pub fn chrome_path(mut self, p: impl Into<PathBuf>) -> Self {
164 self.chrome_path = Some(p.into());
165 self
166 }
167
168 pub fn arg(mut self, a: impl Into<String>) -> Self {
169 self.extra_args.push(a.into());
170 self
171 }
172
173 pub fn args<I, S>(mut self, args: I) -> Self
174 where
175 I: IntoIterator<Item = S>,
176 S: Into<String>,
177 {
178 self.extra_args.extend(args.into_iter().map(Into::into));
179 self
180 }
181
182 pub fn window_size(mut self, w: u32, h: u32) -> Self {
183 self.window_size = Some((w, h));
184 self
185 }
186
187 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
188 self.user_agent = Some(ua.into());
189 self
190 }
191
192 pub fn connect_timeout(mut self, d: Duration) -> Self {
193 self.connect_timeout = d;
194 self
195 }
196
197 pub fn startup_timeout(mut self, d: Duration) -> Self {
198 self.startup_timeout = d;
199 self
200 }
201
202 pub fn command_timeout(mut self, d: Duration) -> Self {
203 self.command_timeout = d;
204 self
205 }
206
207 pub fn keep_alive_on_drop(mut self, v: bool) -> Self {
208 self.keep_alive_on_drop = v;
209 self
210 }
211
212 pub fn auto_relaunch(mut self, v: bool) -> Self {
213 self.auto_relaunch = v;
214 self
215 }
216
217 pub fn no_sandbox(mut self, v: bool) -> Self {
218 self.no_sandbox = Some(v);
219 self
220 }
221
222 pub fn build(self) -> BrowserConfig {
223 BrowserConfig {
224 mode: self.mode,
225 host: self.host,
226 port: self.port,
227 headless: self.headless,
228 enable_automation: self.enable_automation,
229 profile: self.profile,
230 proxy: self.proxy,
231 chrome_path: self.chrome_path,
232 extra_args: self.extra_args,
233 window_size: self.window_size,
234 user_agent: self.user_agent,
235 connect_timeout: self.connect_timeout,
236 startup_timeout: self.startup_timeout,
237 command_timeout: self.command_timeout,
238 keep_alive_on_drop: self.keep_alive_on_drop,
239 auto_relaunch: self.auto_relaunch,
240 no_sandbox: self.no_sandbox,
241 }
242 }
243}
244
245impl Default for BrowserConfigBuilder {
246 fn default() -> Self {
247 Self {
248 mode: LaunchMode::Auto,
249 host: "127.0.0.1".to_string(),
250 port: 0,
251 headless: false,
252 enable_automation: false,
253 profile: ProfileMode::Ephemeral,
254 proxy: None,
255 chrome_path: None,
256 extra_args: Vec::new(),
257 window_size: None,
258 user_agent: None,
259 connect_timeout: Duration::from_secs(10),
260 startup_timeout: Duration::from_secs(10),
261 command_timeout: Duration::from_secs(10),
262 keep_alive_on_drop: false,
263 auto_relaunch: false,
264 no_sandbox: None,
265 }
266 }
267}
268
269fn is_local_host(host: &str) -> bool {
270 let h = host.trim();
271 matches!(h, "127.0.0.1" | "::1" | "[::1]") || h.eq_ignore_ascii_case("localhost")
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 fn ok_config() -> BrowserConfig {
279 BrowserConfig::builder()
280 .mode(LaunchMode::AttachOnly)
281 .port(9222)
282 .build()
283 }
284
285 #[test]
286 #[allow(clippy::cognitive_complexity)]
290 fn given_default_builder_when_built_then_defaults_are_correct() {
291 let cfg = BrowserConfig::builder().build();
292 assert_eq!(cfg.mode, LaunchMode::Auto);
293 assert_eq!(cfg.host, "127.0.0.1");
294 assert_eq!(cfg.port, 0);
295 assert!(!cfg.headless);
296 assert!(!cfg.enable_automation);
297 assert_eq!(cfg.profile, ProfileMode::Ephemeral);
298 assert!(cfg.proxy.is_none());
299 assert!(cfg.chrome_path.is_none());
300 assert!(cfg.extra_args.is_empty());
301 assert!(cfg.window_size.is_none());
302 assert!(cfg.user_agent.is_none());
303 assert_eq!(cfg.connect_timeout, Duration::from_secs(10));
304 assert_eq!(cfg.startup_timeout, Duration::from_secs(10));
305 assert_eq!(cfg.command_timeout, Duration::from_secs(10));
306 assert!(!cfg.keep_alive_on_drop);
307 assert!(!cfg.auto_relaunch);
308 assert!(cfg.no_sandbox.is_none());
309 }
310
311 #[test]
312 fn given_attach_only_with_port_zero_when_validated_then_invalid_config() {
313 let cfg = BrowserConfig::builder()
314 .mode(LaunchMode::AttachOnly)
315 .build();
316 match cfg.validate() {
317 Err(BrowserError::InvalidConfig(msg)) => {
318 assert!(msg.contains("port"), "missing 'port' in: {msg}");
319 }
320 other => panic!("expected InvalidConfig, got {other:?}"),
321 }
322 }
323
324 #[test]
325 fn given_auto_with_port_zero_when_validated_then_invalid_config() {
326 let cfg = BrowserConfig::builder().build();
327 match cfg.validate() {
328 Err(BrowserError::InvalidConfig(msg)) => {
329 assert!(msg.contains("port"), "missing 'port' in: {msg}");
330 }
331 other => panic!("expected InvalidConfig, got {other:?}"),
332 }
333 }
334
335 #[test]
336 fn given_remote_host_with_auto_when_validated_then_invalid_config() {
337 let cfg = BrowserConfig::builder()
338 .host("10.0.0.5")
339 .mode(LaunchMode::Auto)
340 .port(9222)
341 .build();
342 match cfg.validate() {
343 Err(BrowserError::InvalidConfig(msg)) => {
344 assert!(
345 msg.to_lowercase().contains("remote"),
346 "missing 'remote' in: {msg}"
347 );
348 }
349 other => panic!("expected InvalidConfig, got {other:?}"),
350 }
351 }
352
353 #[test]
354 fn given_remote_host_with_launch_new_when_validated_then_invalid_config() {
355 let cfg = BrowserConfig::builder()
356 .host("10.0.0.5")
357 .mode(LaunchMode::LaunchNew)
358 .build();
359 match cfg.validate() {
360 Err(BrowserError::InvalidConfig(msg)) => {
361 assert!(
362 msg.to_lowercase().contains("remote"),
363 "missing 'remote' in: {msg}"
364 );
365 }
366 other => panic!("expected InvalidConfig, got {other:?}"),
367 }
368 }
369
370 #[test]
371 fn given_remote_host_with_attach_only_when_validated_then_ok() {
372 let cfg = BrowserConfig::builder()
373 .mode(LaunchMode::AttachOnly)
374 .host("10.0.0.5")
375 .port(9222)
376 .build();
377 cfg.validate()
378 .expect("AttachOnly with remote host must validate");
379 }
380
381 #[test]
382 fn given_attach_only_with_local_port_when_validated_then_ok() {
383 ok_config()
384 .validate()
385 .expect("local AttachOnly must validate");
386 }
387
388 #[test]
389 fn given_launch_new_with_port_zero_when_validated_then_ok() {
390 let cfg = BrowserConfig::builder().mode(LaunchMode::LaunchNew).build();
391 cfg.validate()
392 .expect("LaunchNew with ephemeral port must validate");
393 }
394
395 #[test]
396 fn given_custom_args_when_built_then_preserved_in_order() {
397 let cfg = BrowserConfig::builder()
398 .arg("--foo=1")
399 .arg("--bar=2")
400 .args(["--baz=3", "--qux=4"])
401 .build();
402 assert_eq!(
403 cfg.extra_args,
404 vec![
405 "--foo=1".to_string(),
406 "--bar=2".to_string(),
407 "--baz=3".to_string(),
408 "--qux=4".to_string(),
409 ]
410 );
411 }
412
413 #[test]
414 fn given_proxy_args_window_size_when_built_then_preserved() {
415 let cfg = BrowserConfig::builder()
416 .proxy("http://p:8080")
417 .arg("--lang=es")
418 .window_size(1280, 800)
419 .user_agent("ua/1.0")
420 .build();
421 assert_eq!(cfg.proxy.as_deref(), Some("http://p:8080"));
422 assert_eq!(cfg.extra_args, vec!["--lang=es".to_string()]);
423 assert_eq!(cfg.window_size, Some((1280, 800)));
424 assert_eq!(cfg.user_agent.as_deref(), Some("ua/1.0"));
425 }
426
427 #[test]
428 fn given_persistent_profile_when_built_then_preserved() {
429 let dir = PathBuf::from("/tmp/profile");
430 let cfg = BrowserConfig::builder()
431 .profile(ProfileMode::Persistent(dir.clone()))
432 .build();
433 assert_eq!(cfg.profile, ProfileMode::Persistent(dir));
434 }
435
436 #[test]
437 fn test_is_local_host() {
438 assert!(is_local_host("127.0.0.1"));
439 assert!(is_local_host("localhost"));
440 assert!(is_local_host("LOCALHOST"));
441 assert!(is_local_host("Localhost"));
442 assert!(is_local_host("::1"));
443 assert!(is_local_host("[::1]"));
444 assert!(is_local_host(" 127.0.0.1 "));
445
446 assert!(!is_local_host("10.0.0.5"));
447 assert!(!is_local_host("192.168.1.1"));
448 assert!(!is_local_host("example.com"));
449 assert!(!is_local_host(""));
450 assert!(!is_local_host("127"));
451 assert!(!is_local_host("localhost.example.com"));
452 assert!(!is_local_host("::2"));
453 }
454}