200 lines
5.6 KiB
Rust
200 lines
5.6 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,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
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");
|
|
}
|
|
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 = "[zoom]\nscroll_step = 1.15\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(), "zoom".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 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"));
|
|
}
|
|
}
|