1use 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,
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
40pub 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
70pub 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
91pub 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
126fn 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 run_get(Key::Default, true).unwrap();
220 });
221 }
222}