Files
valw/src/config.rs
T

272 lines
7.7 KiB
Rust

use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use crate::error::HintExt;
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub save: Save,
pub capture: Capture,
pub preview: Preview,
pub zoom: Zoom,
pub editor: Editor,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct Save {
pub directory: String,
pub filename: String,
pub copy_to_clipboard: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
#[serde(default)]
pub struct Capture {
pub show_cursor: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct Preview {
pub enabled: bool,
pub timeout_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct Zoom {
pub scroll_step: f64,
pub flashlight_radius: f64,
}
impl Default for Zoom {
fn default() -> Self {
Self {
scroll_step: 1.15,
flashlight_radius: 180.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
#[serde(default)]
pub struct Editor {
pub backend: Backend,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
/// `valw edit`.
#[default]
Builtin,
Satty,
}
impl Default for Preview {
fn default() -> Self {
Self {
enabled: true,
timeout_secs: 5,
}
}
}
impl Default for Save {
fn default() -> Self {
Self {
directory: "~/Pictures/Screenshots".into(),
filename: "Screenshot %Y-%m-%d at %H.%M.%S.png".into(),
copy_to_clipboard: true,
}
}
}
impl Config {
/// The save directory with `~` and `$HOME` expanded.
pub fn save_dir(&self) -> PathBuf {
expand_home(&self.save.directory, &home())
}
}
pub fn home() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| "/".into())
}
/// `$XDG_CONFIG_HOME/valw/config.toml`, falling back to `~/.config`.
pub fn default_path() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home().join(".config"))
.join("valw/config.toml")
}
/// Loads the config. A missing file means defaults.
pub fn load(path: &Path) -> Result<Config> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
Err(e) => return Err(e).with_context(|| format!("could not read {}", path.display())),
};
let (config, unknown) = parse(&text)
.with_context(|| format!("invalid config file {}", path.display()))
.hint("fix the file or delete it to use the defaults")?;
for key in unknown {
tracing::warn!("ignoring unknown config key `{key}` in {}", path.display());
}
Ok(config)
}
/// Parses config text. Returns the config and the keys valw doesn't know.
pub fn parse(text: &str) -> Result<(Config, Vec<String>)> {
let de = toml::Deserializer::parse(text)?;
let mut unknown = Vec::new();
let config: Config = serde_ignored::deserialize(de, |path| unknown.push(path.to_string()))?;
if config.preview.timeout_secs == 0 {
bail!("preview.timeout_secs must be at least 1");
}
if config.zoom.scroll_step <= 1.0 {
bail!("zoom.scroll_step must be greater than 1");
}
if !(20.0..=2000.0).contains(&config.zoom.flashlight_radius) {
bail!("zoom.flashlight_radius must be between 20 and 2000");
}
Ok((config, unknown))
}
pub fn expand_home(path: &str, home: &Path) -> PathBuf {
if path == "~" || path == "$HOME" {
return home.to_path_buf();
}
for prefix in ["~/", "$HOME/"] {
if let Some(rest) = path.strip_prefix(prefix) {
return home.join(rest);
}
}
PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_file_gives_defaults() {
let (config, unknown) = parse("").unwrap();
assert_eq!(config, Config::default());
assert!(unknown.is_empty());
}
#[test]
fn partial_file_keeps_other_defaults() {
let (config, _) = parse("[save]\ncopy_to_clipboard = false\n").unwrap();
assert!(!config.save.copy_to_clipboard);
assert_eq!(config.save.directory, "~/Pictures/Screenshots");
assert!(!config.capture.show_cursor);
}
#[test]
fn unknown_keys_are_reported_not_rejected() {
let text = "[toolbar]\nposition = \"top\"\n\n[save]\nfolder = \"x\"\n";
let (config, unknown) = parse(text).unwrap();
assert_eq!(config, Config::default());
assert_eq!(
unknown,
vec!["save.folder".to_string(), "toolbar".to_string()]
);
}
#[test]
fn invalid_toml_names_the_line() {
let err = parse("[save]\ndirectory = \n").unwrap_err();
assert!(format!("{err:#}").contains("line 2"), "{err:#}");
}
#[test]
fn wrong_type_is_an_error() {
assert!(parse("[capture]\nshow_cursor = \"yes\"\n").is_err());
}
#[test]
fn preview_defaults_and_overrides() {
assert_eq!(
Config::default().preview,
Preview {
enabled: true,
timeout_secs: 5
}
);
let (config, _) = parse("[preview]\ntimeout_secs = 2\n").unwrap();
assert!(config.preview.enabled);
assert_eq!(config.preview.timeout_secs, 2);
}
#[test]
fn preview_timeout_must_be_positive() {
let err = parse("[preview]\ntimeout_secs = 0\n").unwrap_err();
assert_eq!(err.to_string(), "preview.timeout_secs must be at least 1");
assert!(parse("[preview]\ntimeout_secs = -3\n").is_err());
}
#[test]
fn zoom_defaults_overrides_and_bounds() {
assert_eq!(
Config::default().zoom,
Zoom {
scroll_step: 1.15,
flashlight_radius: 180.0
}
);
let (config, unknown) = parse("[zoom]\nscroll_step = 1.3\n").unwrap();
assert!(unknown.is_empty());
assert_eq!(config.zoom.scroll_step, 1.3);
assert_eq!(config.zoom.flashlight_radius, 180.0);
let err = parse("[zoom]\nscroll_step = 1.0\n").unwrap_err();
assert_eq!(err.to_string(), "zoom.scroll_step must be greater than 1");
let err = parse("[zoom]\nflashlight_radius = 5.0\n").unwrap_err();
assert_eq!(
err.to_string(),
"zoom.flashlight_radius must be between 20 and 2000"
);
}
#[test]
fn editor_backend_default_and_values() {
assert_eq!(Config::default().editor.backend, Backend::Builtin);
let (config, _) = parse("[editor]\nbackend = \"satty\"\n").unwrap();
assert_eq!(config.editor.backend, Backend::Satty);
assert!(parse("[editor]\nbackend = \"gimp\"\n").is_err());
}
#[test]
fn missing_file_gives_defaults() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
load(&dir.path().join("nope.toml")).unwrap(),
Config::default()
);
}
#[test]
fn expands_home() {
let home = Path::new("/home/u");
assert_eq!(
expand_home("~/Pictures", home),
Path::new("/home/u/Pictures")
);
assert_eq!(
expand_home("$HOME/Pictures", home),
Path::new("/home/u/Pictures")
);
assert_eq!(expand_home("~", home), Path::new("/home/u"));
assert_eq!(expand_home("/srv/shots", home), Path::new("/srv/shots"));
assert_eq!(expand_home("~other/x", home), Path::new("~other/x"));
}
}