use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::PathBuf;
use crate::paths;
const HEADER: &str =
"# Managed by browser-control. Edit with `browser-control set <key> <value>`.\n";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
impl Config {
pub fn is_empty(&self) -> bool {
self.default.is_none()
}
}
pub fn load() -> Result<Config> {
load_from(&paths::config_file_path()?)
}
pub fn save(cfg: &Config) -> Result<()> {
save_to(&paths::config_file_path()?, cfg)
}
pub(crate) fn load_from(path: &PathBuf) -> Result<Config> {
match std::fs::read_to_string(path) {
Ok(s) => toml::from_str::<Config>(&s)
.with_context(|| format!("parsing config file {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
Err(e) => Err(anyhow::Error::new(e))
.with_context(|| format!("reading config file {}", path.display())),
}
}
pub(crate) fn save_to(path: &PathBuf, cfg: &Config) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating config dir {}", parent.display()))?;
}
let body = toml::to_string_pretty(cfg).context("serializing config to TOML")?;
let mut contents = String::with_capacity(HEADER.len() + body.len());
contents.push_str(HEADER);
contents.push_str(&body);
let tmp = path.with_extension("toml.tmp");
{
let mut f = std::fs::File::create(&tmp)
.with_context(|| format!("creating tmp config file {}", tmp.display()))?;
f.write_all(contents.as_bytes())
.with_context(|| format!("writing tmp config file {}", tmp.display()))?;
f.sync_all().ok();
}
std::fs::rename(&tmp, path)
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_cfg() -> (tempfile::TempDir, PathBuf) {
let td = tempfile::TempDir::new().unwrap();
let p = td.path().join("config.toml");
(td, p)
}
#[test]
fn missing_file_yields_default() {
let (_td, p) = tmp_cfg();
let cfg = load_from(&p).unwrap();
assert_eq!(cfg, Config::default());
assert!(cfg.is_empty());
}
#[test]
fn round_trip_default() {
let (_td, p) = tmp_cfg();
let cfg = Config {
default: Some("firefox".into()),
};
save_to(&p, &cfg).unwrap();
let read = load_from(&p).unwrap();
assert_eq!(read, cfg);
let text = std::fs::read_to_string(&p).unwrap();
assert!(text.starts_with("# Managed by browser-control"));
assert!(text.contains("default = \"firefox\""));
}
#[test]
fn save_clears_when_default_is_none() {
let (_td, p) = tmp_cfg();
save_to(
&p,
&Config {
default: Some("chrome".into()),
},
)
.unwrap();
save_to(&p, &Config::default()).unwrap();
let read = load_from(&p).unwrap();
assert!(read.is_empty());
let text = std::fs::read_to_string(&p).unwrap();
assert!(
!text.contains("default ="),
"expected key to be absent, got: {text}"
);
}
#[test]
fn malformed_file_is_an_error() {
let (_td, p) = tmp_cfg();
std::fs::write(&p, "this is = not [valid toml").unwrap();
let err = load_from(&p).unwrap_err();
let msg = format!("{err:#}").to_lowercase();
assert!(msg.contains("parsing config file"), "got: {msg}");
}
}