//! Decides where pkgwatch's config, state, and work directories live on //! disk — the only module that reads the environment to answer that; every //! other module takes the directories it needs as parameters. (The pacman //! repo dir, `pipeline::custom_repo_dir`, is resolved separately.) //! //! These follow the XDG base-directory spec instead of the current working //! directory, so an installed `/usr/bin/pkgwatch` behaves the same //! wherever it's launched from (a systemd unit, a shell, another checkout) //! instead of only working from inside the repo. use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; const APP_DIR: &str = "pkgwatch"; #[derive(Debug, PartialEq, Eq)] pub struct Paths { /// `*.toml` package declarations. Config: hand-edited, worth backing up. pub packages_dir: PathBuf, /// Last-published/pending versions. State: small, but losing it makes /// every package look new, so it isn't cache. pub state_dir: PathBuf, /// Downloaded artifacts and build trees. Cache: safe to delete. pub work_dir: PathBuf, } impl Paths { pub fn from_env() -> Result { Self::resolve(|key| std::env::var(key).ok()) } /// `getenv` is injectable so tests don't mutate the process-global /// environment, which would race with `cargo test`'s parallel threads. /// /// Each directory has a pkgwatch-specific override, then the matching /// XDG variable, then the XDG default under `$HOME`; empty counts as /// unset. The overrides exist for dry runs against scratch /// directories, like `PKGWATCH_REPO_DIR` does for the pacman repo, so /// they're used verbatim. The XDG variables and `$HOME` must be /// absolute: the spec says to ignore a relative XDG value, and honoring /// one would bring back the cwd dependence this module exists to remove. fn resolve(getenv: impl Fn(&str) -> Option) -> Result { let base = |override_var: &str, xdg_var: &str, home_subpath: &str| -> Result { if let Some(dir) = non_empty(&getenv, override_var) { return Ok(PathBuf::from(dir)); } if let Some(dir) = absolute(&getenv, xdg_var) { return Ok(Path::new(&dir).join(APP_DIR)); } let home = absolute(&getenv, "HOME").context("HOME is not set to an absolute path")?; Ok(Path::new(&home).join(home_subpath).join(APP_DIR)) }; Ok(Self { packages_dir: base("PKGWATCH_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")? .join("packages.d"), state_dir: base("PKGWATCH_STATE_DIR", "XDG_STATE_HOME", ".local/state")?, work_dir: base("PKGWATCH_WORK_DIR", "XDG_CACHE_HOME", ".cache")?, }) } } /// The XDG spec says an empty variable must be treated as unset. fn non_empty(getenv: &impl Fn(&str) -> Option, key: &str) -> Option { getenv(key).filter(|v| !v.is_empty()) } /// Like `non_empty`, but also drops relative values (an empty string isn't /// absolute either, so this subsumes the empty check). fn absolute(getenv: &impl Fn(&str) -> Option, key: &str) -> Option { getenv(key).filter(|v| Path::new(v).is_absolute()) } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { let map: HashMap = pairs .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); move |key| map.get(key).cloned() } #[test] fn defaults_live_under_home() { let paths = Paths::resolve(env(&[("HOME", "/home/u")])).unwrap(); assert_eq!( paths, Paths { packages_dir: "/home/u/.config/pkgwatch/packages.d".into(), state_dir: "/home/u/.local/state/pkgwatch".into(), work_dir: "/home/u/.cache/pkgwatch".into(), } ); } #[test] fn xdg_variables_override_home_defaults() { let paths = Paths::resolve(env(&[ ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/xdg/config"), ("XDG_STATE_HOME", "/xdg/state"), ("XDG_CACHE_HOME", "/xdg/cache"), ])) .unwrap(); assert_eq!( paths.packages_dir, Path::new("/xdg/config/pkgwatch/packages.d") ); assert_eq!(paths.state_dir, Path::new("/xdg/state/pkgwatch")); assert_eq!(paths.work_dir, Path::new("/xdg/cache/pkgwatch")); } #[test] fn pkgwatch_overrides_win_and_are_used_verbatim() { let paths = Paths::resolve(env(&[ ("HOME", "/home/u"), ("XDG_STATE_HOME", "/xdg/state"), ("PKGWATCH_CONFIG_DIR", "/scratch/cfg"), ("PKGWATCH_STATE_DIR", "/scratch/state"), ("PKGWATCH_WORK_DIR", "/scratch/work"), ])) .unwrap(); // No `pkgwatch/` suffix appended to an explicit override. assert_eq!(paths.packages_dir, Path::new("/scratch/cfg/packages.d")); assert_eq!(paths.state_dir, Path::new("/scratch/state")); assert_eq!(paths.work_dir, Path::new("/scratch/work")); } #[test] fn empty_variables_are_treated_as_unset() { let paths = Paths::resolve(env(&[ ("HOME", "/home/u"), ("XDG_STATE_HOME", ""), ("PKGWATCH_WORK_DIR", ""), ])) .unwrap(); assert_eq!(paths.state_dir, Path::new("/home/u/.local/state/pkgwatch")); assert_eq!(paths.work_dir, Path::new("/home/u/.cache/pkgwatch")); } #[test] fn relative_xdg_variables_are_ignored() { let paths = Paths::resolve(env(&[ ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "rel/config"), ("XDG_STATE_HOME", "./state"), ("XDG_CACHE_HOME", "cache"), ])) .unwrap(); assert_eq!( paths.packages_dir, Path::new("/home/u/.config/pkgwatch/packages.d") ); assert_eq!(paths.state_dir, Path::new("/home/u/.local/state/pkgwatch")); assert_eq!(paths.work_dir, Path::new("/home/u/.cache/pkgwatch")); } #[test] fn relative_home_is_an_error() { let err = Paths::resolve(env(&[("HOME", "relative/home")])).unwrap_err(); assert!(err.to_string().contains("HOME")); } #[test] fn errors_when_nothing_locates_home() { let err = Paths::resolve(env(&[])).unwrap_err(); assert!(err.to_string().contains("HOME")); } #[test] fn no_home_needed_when_every_dir_is_overridden() { let paths = Paths::resolve(env(&[ ("PKGWATCH_CONFIG_DIR", "/c"), ("PKGWATCH_STATE_DIR", "/s"), ("PKGWATCH_WORK_DIR", "/w"), ])) .unwrap(); assert_eq!(paths.state_dir, Path::new("/s")); } }