Skip to main content

browser_control/
config.rs

1//! Persistent user configuration (TOML at `<config_dir>/config.toml`).
2//!
3//! Edited via the `browser-control set|get|unset` subcommands.
4
5use 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
27/// Load the config from disk. A missing file yields `Config::default()`.
28pub fn load() -> Result<Config> {
29    load_from(&paths::config_file_path()?)
30}
31
32/// Save `cfg` to disk atomically.
33pub 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}