browser_control/
config.rs1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::io::Write;
8use std::path::PathBuf;
9
10use crate::paths;
11
12const HEADER: &str =
13 "# Managed by browser-control. Edit with `browser-control set <key> <value>`.\n";
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Config {
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub default: Option<String>,
19}
20
21impl Config {
22 pub fn is_empty(&self) -> bool {
23 self.default.is_none()
24 }
25}
26
27pub fn load() -> Result<Config> {
29 load_from(&paths::config_file_path()?)
30}
31
32pub fn save(cfg: &Config) -> Result<()> {
34 save_to(&paths::config_file_path()?, cfg)
35}
36
37pub(crate) fn load_from(path: &PathBuf) -> Result<Config> {
38 match std::fs::read_to_string(path) {
39 Ok(s) => toml::from_str::<Config>(&s)
40 .with_context(|| format!("parsing config file {}", path.display())),
41 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
42 Err(e) => Err(anyhow::Error::new(e))
43 .with_context(|| format!("reading config file {}", path.display())),
44 }
45}
46
47pub(crate) fn save_to(path: &PathBuf, cfg: &Config) -> Result<()> {
48 if let Some(parent) = path.parent() {
49 std::fs::create_dir_all(parent)
50 .with_context(|| format!("creating config dir {}", parent.display()))?;
51 }
52 let body = toml::to_string_pretty(cfg).context("serializing config to TOML")?;
53 let mut contents = String::with_capacity(HEADER.len() + body.len());
54 contents.push_str(HEADER);
55 contents.push_str(&body);
56
57 let tmp = path.with_extension("toml.tmp");
58 {
59 let mut f = std::fs::File::create(&tmp)
60 .with_context(|| format!("creating tmp config file {}", tmp.display()))?;
61 f.write_all(contents.as_bytes())
62 .with_context(|| format!("writing tmp config file {}", tmp.display()))?;
63 f.sync_all().ok();
64 }
65 std::fs::rename(&tmp, path)
66 .with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
67 Ok(())
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 fn tmp_cfg() -> (tempfile::TempDir, PathBuf) {
75 let td = tempfile::TempDir::new().unwrap();
76 let p = td.path().join("config.toml");
77 (td, p)
78 }
79
80 #[test]
81 fn missing_file_yields_default() {
82 let (_td, p) = tmp_cfg();
83 let cfg = load_from(&p).unwrap();
84 assert_eq!(cfg, Config::default());
85 assert!(cfg.is_empty());
86 }
87
88 #[test]
89 fn round_trip_default() {
90 let (_td, p) = tmp_cfg();
91 let cfg = Config {
92 default: Some("firefox".into()),
93 };
94 save_to(&p, &cfg).unwrap();
95 let read = load_from(&p).unwrap();
96 assert_eq!(read, cfg);
97
98 let text = std::fs::read_to_string(&p).unwrap();
99 assert!(text.starts_with("# Managed by browser-control"));
100 assert!(text.contains("default = \"firefox\""));
101 }
102
103 #[test]
104 fn save_clears_when_default_is_none() {
105 let (_td, p) = tmp_cfg();
106 save_to(
107 &p,
108 &Config {
109 default: Some("chrome".into()),
110 },
111 )
112 .unwrap();
113 save_to(&p, &Config::default()).unwrap();
114 let read = load_from(&p).unwrap();
115 assert!(read.is_empty());
116 let text = std::fs::read_to_string(&p).unwrap();
117 assert!(
118 !text.contains("default ="),
119 "expected key to be absent, got: {text}"
120 );
121 }
122
123 #[test]
124 fn malformed_file_is_an_error() {
125 let (_td, p) = tmp_cfg();
126 std::fs::write(&p, "this is = not [valid toml").unwrap();
127 let err = load_from(&p).unwrap_err();
128 let msg = format!("{err:#}").to_lowercase();
129 assert!(msg.contains("parsing config file"), "got: {msg}");
130 }
131}