Skip to main content

cdp_browser_lite/
config.rs

1use std::path::PathBuf;
2use std::time::Duration;
3
4use crate::error::BrowserError;
5
6/// Defines how the browser instance should be obtained.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum LaunchMode {
9    /// Tries to attach if the port is open, otherwise launches a new instance.
10    Auto,
11    /// Always launches a new instance.
12    LaunchNew,
13    /// Only attaches to an existing instance.
14    AttachOnly,
15}
16
17/// Defines the type of profile to use for the browser.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ProfileMode {
20    /// Creates a temporary profile that is deleted when the browser is stopped.
21    Ephemeral,
22    /// Uses a persistent profile at the specified path.
23    Persistent(PathBuf),
24    /// Uses the default user profile.
25    UserDefault,
26}
27
28/// Configuration for launching or attaching to a browser.
29#[derive(Debug, Clone)]
30// Independent launch flags; grouping them into a sub-struct would only obscure
31// the flat, self-documenting configuration surface.
32#[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// Mirrors `BrowserConfig`'s independent launch flags (see note above).
106#[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    // Linear field-by-field assertions on the builder defaults; `BrowserConfig`
287    // deliberately does not derive `PartialEq`, so a single `assert_eq!` is not
288    // an option and the assertion count exceeds the (lowered) complexity budget.
289    #[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}