2026-09-20 08:01:54 +00:00
|
|
|
//! Decides where pkgwatch's config, state, and work directories live on
|
2026-09-20 08:19:12 +00:00
|
|
|
//! 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.)
|
2026-09-20 08:01:54 +00:00
|
|
|
//!
|
|
|
|
|
//! 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";
|
|
|
|
|
|
2026-09-20 08:19:12 +00:00
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
2026-09-20 08:01:54 +00:00
|
|
|
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> {
|
|
|
|
|
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
|
2026-09-20 08:19:12 +00:00
|
|
|
/// 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.
|
2026-09-20 08:01:54 +00:00
|
|
|
fn resolve(getenv: impl Fn(&str) -> Option<String>) -> Result<Self> {
|
|
|
|
|
let base = |override_var: &str, xdg_var: &str, home_subpath: &str| -> Result<PathBuf> {
|
|
|
|
|
if let Some(dir) = non_empty(&getenv, override_var) {
|
|
|
|
|
return Ok(PathBuf::from(dir));
|
|
|
|
|
}
|
2026-09-20 08:19:12 +00:00
|
|
|
if let Some(dir) = absolute(&getenv, xdg_var) {
|
2026-09-20 08:01:54 +00:00
|
|
|
return Ok(Path::new(&dir).join(APP_DIR));
|
|
|
|
|
}
|
2026-09-20 08:19:12 +00:00
|
|
|
let home = absolute(&getenv, "HOME").context("HOME is not set to an absolute path")?;
|
2026-09-20 08:01:54 +00:00
|
|
|
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<String>, key: &str) -> Option<String> {
|
|
|
|
|
getenv(key).filter(|v| !v.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 08:19:12 +00:00
|
|
|
/// 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<String>, key: &str) -> Option<String> {
|
|
|
|
|
getenv(key).filter(|v| Path::new(v).is_absolute())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 08:01:54 +00:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
|
|
|
|
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
|
|
|
|
let map: HashMap<String, String> = 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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 08:19:12 +00:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 08:01:54 +00:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
}
|