Skip to main content

browser_control/cli/
set.rs

1//! `set` / `get` / `unset` subcommand handlers.
2//!
3//! Currently the only supported key is `default`, which selects the browser
4//! to use when `BROWSER_CONTROL` is unset and no positional argument is given.
5
6use anyhow::{anyhow, Context, Result};
7use serde::Serialize;
8
9use crate::cli::env_resolver::{self, BrowserSelector};
10use crate::cli::output::print_json;
11use crate::config::{self, Config};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
14pub enum Key {
15    /// Default browser to connect to when `BROWSER_CONTROL` and CLI args are absent.
16    Default,
17}
18
19impl Key {
20    pub fn as_str(self) -> &'static str {
21        match self {
22            Key::Default => "default",
23        }
24    }
25}
26
27#[derive(Debug, Serialize)]
28struct KeyValue<'a> {
29    key: &'a str,
30    value: Option<&'a str>,
31}
32
33#[derive(Debug, Serialize)]
34struct ChangeResult<'a> {
35    key: &'a str,
36    value: Option<&'a str>,
37    changed: bool,
38}
39
40/// `browser-control set <key> <value>`. With no value, returns an error
41/// pointing at `unset`.
42pub fn run_set(key: Key, value: Option<String>, json: bool) -> Result<()> {
43    let value = value.ok_or_else(|| {
44        anyhow!(
45            "missing value for `{}`; pass a value, or use `browser-control unset {}` to clear it",
46            key.as_str(),
47            key.as_str()
48        )
49    })?;
50    let canonical = canonicalize(key, &value)
51        .with_context(|| format!("validating value for `{}`", key.as_str()))?;
52
53    let mut cfg = config::load()?;
54    let previous = take(key, &mut cfg);
55    set(key, &mut cfg, Some(canonical.clone()));
56    config::save(&cfg)?;
57    let changed = previous.as_deref() != Some(canonical.as_str());
58
59    emit_change(
60        ChangeResult {
61            key: key.as_str(),
62            value: Some(canonical.as_str()),
63            changed,
64        },
65        json,
66        "set",
67    )
68}
69
70/// `browser-control get <key> [--json]`.
71pub fn run_get(key: Key, json: bool) -> Result<()> {
72    let cfg = config::load()?;
73    let value = get(key, &cfg);
74    if json {
75        print_json(
76            &mut std::io::stdout(),
77            &KeyValue {
78                key: key.as_str(),
79                value: value.as_deref(),
80            },
81        )?;
82    } else {
83        match value.as_deref() {
84            Some(v) => println!("{} = {v}", key.as_str()),
85            None => println!("{} = <unset>", key.as_str()),
86        }
87    }
88    Ok(())
89}
90
91/// `browser-control unset <key>`.
92pub fn run_unset(key: Key, json: bool) -> Result<()> {
93    let mut cfg = config::load()?;
94    let previous = take(key, &mut cfg);
95    config::save(&cfg)?;
96
97    emit_change(
98        ChangeResult {
99            key: key.as_str(),
100            value: None,
101            changed: previous.is_some(),
102        },
103        json,
104        "unset",
105    )
106}
107
108fn get(key: Key, cfg: &Config) -> Option<String> {
109    match key {
110        Key::Default => cfg.default.clone(),
111    }
112}
113
114fn set(key: Key, cfg: &mut Config, value: Option<String>) {
115    match key {
116        Key::Default => cfg.default = value,
117    }
118}
119
120fn take(key: Key, cfg: &mut Config) -> Option<String> {
121    match key {
122        Key::Default => cfg.default.take(),
123    }
124}
125
126/// Validate `value` against the rules for `key` and return its canonical form.
127fn canonicalize(key: Key, value: &str) -> Result<String> {
128    match key {
129        Key::Default => {
130            let sel = env_resolver::parse(value)?;
131            Ok(match sel {
132                BrowserSelector::Url(u) => u.to_string(),
133                BrowserSelector::Kind(k) => k.as_str().to_string(),
134                BrowserSelector::ExecutablePath(p) => p.display().to_string(),
135                BrowserSelector::Name(n) => n,
136            })
137        }
138    }
139}
140
141fn emit_change(res: ChangeResult<'_>, json: bool, verb: &str) -> Result<()> {
142    if json {
143        print_json(&mut std::io::stdout(), &res)?;
144    } else {
145        let suffix = if res.changed { "" } else { " (no change)" };
146        match res.value {
147            Some(v) => println!("{verb} {} = {v}{suffix}", res.key),
148            None => println!("{verb} {}{suffix}", res.key),
149        }
150    }
151    Ok(())
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn with_tmp_config<R>(f: impl FnOnce() -> R) -> R {
159        let _g = crate::test_support::ENV_LOCK
160            .lock()
161            .unwrap_or_else(|e| e.into_inner());
162        let td = tempfile::TempDir::new().unwrap();
163        std::env::set_var("BROWSER_CONTROL_CONFIG_DIR", td.path());
164        let r = f();
165        std::env::remove_var("BROWSER_CONTROL_CONFIG_DIR");
166        drop(td);
167        r
168    }
169
170    #[test]
171    fn set_default_kind_canonicalizes_to_lowercase() {
172        with_tmp_config(|| {
173            run_set(Key::Default, Some("FIREFOX".into()), true).unwrap();
174            let cfg = config::load().unwrap();
175            assert_eq!(cfg.default.as_deref(), Some("firefox"));
176        });
177    }
178
179    #[test]
180    fn set_default_url_stored_verbatim() {
181        with_tmp_config(|| {
182            let v = "ws://127.0.0.1:9222/devtools/browser/abc";
183            run_set(Key::Default, Some(v.into()), true).unwrap();
184            let cfg = config::load().unwrap();
185            assert_eq!(cfg.default.as_deref(), Some(v));
186        });
187    }
188
189    #[test]
190    fn set_default_empty_rejected() {
191        with_tmp_config(|| {
192            let err = run_set(Key::Default, Some(String::new()), true).unwrap_err();
193            assert!(format!("{err:#}").contains("validating value"));
194        });
195    }
196
197    #[test]
198    fn set_default_no_value_rejected() {
199        with_tmp_config(|| {
200            let err = run_set(Key::Default, None, true).unwrap_err();
201            assert!(format!("{err:#}").contains("missing value"));
202        });
203    }
204
205    #[test]
206    fn unset_removes_default() {
207        with_tmp_config(|| {
208            run_set(Key::Default, Some("chrome".into()), true).unwrap();
209            run_unset(Key::Default, true).unwrap();
210            let cfg = config::load().unwrap();
211            assert!(cfg.default.is_none());
212        });
213    }
214
215    #[test]
216    fn get_returns_unset_when_absent() {
217        with_tmp_config(|| {
218            // Should not error even with no file on disk.
219            run_get(Key::Default, true).unwrap();
220        });
221    }
222}