From 413d467a3cfb0b17154a5797eca31cd171504f0e Mon Sep 17 00:00:00 2001 From: Magrathean UK <88115882+magrathean-uk@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:46:20 +0100 Subject: [PATCH] Add RUSTIC_HOME and XDG profile search paths --- config/README.md | 16 ++++- src/config.rs | 95 ++++++++++++++++++++++++----- tests/config_paths.rs | 139 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 17 deletions(-) create mode 100644 tests/config_paths.rs diff --git a/config/README.md b/config/README.md index edd34a3ad..5c1b6ab03 100644 --- a/config/README.md +++ b/config/README.md @@ -59,9 +59,19 @@ options. Therefore `commandline arguments` have the highest precedence. Configuration files can be placed in the user's local config directory, e.g. `~/.config/rustic/` or in the global config dir, e.g. `/etc/rustic/`. You can use different config files, e.g. `myconfig.toml` and use the `-P` option to -specify the profile name, e.g. `rustic -P myconfig`. env variables can be -substituted in config files using the `profile-substitute-env` option. This -allows extra customization possibilities. +specify the profile name, e.g. `rustic -P myconfig`. + +On Windows, rustic also searches `%USERPROFILE%\.config\rustic\` after the +platform user config directory and before the global config directory. + +To override these locations, set `RUSTIC_HOME` to an absolute rustic +installation home. rustic searches `RUSTIC_HOME/config/` first, then +`XDG_CONFIG_HOME/rustic/` and each `XDG_CONFIG_DIRS/rustic/` directory in +order, before its platform user and global configuration directories. Relative +environment directory entries are ignored. + +Environment variables can be substituted in config files using the +`profile-substitute-env` option. This allows extra customization possibilities. Examples for different configuration files can be found here in the [/config/](/config) directory. diff --git a/src/config.rs b/src/config.rs index c682d0c50..f92ad5735 100644 --- a/src/config.rs +++ b/src/config.rs @@ -314,20 +314,77 @@ impl GlobalOptions { /// /// A vector of [`PathBuf`]s to the config files fn get_config_paths(filename: &str) -> Vec { - [ - ProjectDirs::from("", "", "rustic") - .map(|project_dirs| project_dirs.config_dir().to_path_buf()), - get_global_config_path(), - Some(PathBuf::from(".")), - ] - .into_iter() - .filter_map(|path| { - path.map(|mut p| { - p.push(filename); - p - }) - }) - .collect() + get_rustic_home_config_dir() + .into_iter() + .chain(get_xdg_config_dirs()) + .chain( + [ + ProjectDirs::from("", "", "rustic") + .map(|project_dirs| project_dirs.config_dir().to_path_buf()), + get_user_config_path(), + get_global_config_path(), + Some(PathBuf::from(".")), + ] + .into_iter() + .flatten(), + ) + .unique() + .map(|path| path.join(filename)) + .collect() +} + +/// Get the profile directory configured through `RUSTIC_HOME`. +/// +/// An absolute `RUSTIC_HOME` points to a rustic installation home, whose +/// profiles are stored in its `config` directory. Relative values are ignored. +fn get_rustic_home_config_dir() -> Option { + std::env::var_os("RUSTIC_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .map(|path| path.join("config")) +} + +/// Get profile directories configured through the XDG environment variables. +/// +/// `XDG_CONFIG_HOME` is searched first, followed by the directories in +/// `XDG_CONFIG_DIRS`. Relative paths are ignored as required by the XDG Base +/// Directory Specification. +fn get_xdg_config_dirs() -> Vec { + std::env::var_os("XDG_CONFIG_HOME") + .into_iter() + .map(PathBuf::from) + .chain( + std::env::var_os("XDG_CONFIG_DIRS") + .as_deref() + .into_iter() + .flat_map(std::env::split_paths), + ) + .filter(|path| path.is_absolute()) + .map(|path| path.join("rustic")) + .unique() + .collect() +} + +/// Get the user-managed config directory on Windows. +/// +/// Besides the platform configuration directory returned by [`ProjectDirs`], +/// also look in the XDG-style location commonly used by command-line tools on +/// Windows. +#[cfg(target_os = "windows")] +fn get_user_config_path() -> Option { + std::env::var_os("USERPROFILE").map(user_config_path) +} + +#[cfg(any(test, target_os = "windows"))] +fn user_config_path(user_profile: impl Into) -> PathBuf { + user_profile.into().join(".config").join("rustic") +} + +/// There is no additional user-managed config directory on non-Windows +/// platforms: [`ProjectDirs`] already supplies the conventional location. +#[cfg(not(target_os = "windows"))] +fn get_user_config_path() -> Option { + None } /// Get the path to the global config directory on Windows. @@ -408,4 +465,14 @@ mod tests { // Check Debug assert_debug_snapshot!(deserialized); } + + #[test] + fn windows_user_config_path_uses_dot_config_directory() { + assert_eq!( + user_config_path(r"C:\Users\rustic"), + PathBuf::from(r"C:\Users\rustic") + .join(".config") + .join("rustic") + ); + } } diff --git a/tests/config_paths.rs b/tests/config_paths.rs new file mode 100644 index 000000000..e76e71d25 --- /dev/null +++ b/tests/config_paths.rs @@ -0,0 +1,139 @@ +//! Profile lookup tests using isolated child-process environments. + +use std::{env, fs}; + +use assert_cmd::Command; +use predicates::prelude::*; +use rustic_testing::TestResult; +use tempfile::tempdir; + +fn write_profile(directory: &std::path::Path, name: &str, repository: &str) -> TestResult<()> { + let profile_dir = directory.join("rustic"); + fs::create_dir_all(&profile_dir)?; + fs::write( + profile_dir.join(format!("{name}.toml")), + format!("[repository]\nrepository = \"{repository}\"\n"), + )?; + Ok(()) +} + +fn write_rustic_home_profile( + rustic_home: &std::path::Path, + name: &str, + repository: &str, +) -> TestResult<()> { + let config_dir = rustic_home.join("config"); + fs::create_dir_all(&config_dir)?; + fs::write( + config_dir.join(format!("{name}.toml")), + format!("[repository]\nrepository = \"{repository}\"\n"), + )?; + Ok(()) +} + +fn show_config(profile: &str) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_rustic")); + command + .env_remove("RUSTIC_HOME") + .env_remove("RUSTIC_REPOSITORY") + .env_remove("RUSTIC_USE_PROFILE") + .args(["--use-profile", profile, "show-config"]); + command +} + +#[test] +fn xdg_config_home_precedes_xdg_config_dirs() -> TestResult<()> { + let temp_dir = tempdir()?; + let config_home = temp_dir.path().join("config-home"); + let config_dir = temp_dir.path().join("config-dir"); + let profile = "xdg-home-precedence"; + write_profile(&config_home, profile, "home-marker")?; + write_profile(&config_dir, profile, "directory-marker")?; + + show_config(profile) + .current_dir(temp_dir.path()) + .env("XDG_CONFIG_HOME", &config_home) + .env("XDG_CONFIG_DIRS", env::join_paths([&config_dir])?) + .assert() + .success() + .stdout(predicate::str::contains("repository = \"home-marker\"")) + .stdout(predicate::str::contains("directory-marker").not()); + + Ok(()) +} + +#[test] +fn xdg_config_dirs_are_searched_in_order() -> TestResult<()> { + let temp_dir = tempdir()?; + let config_home = temp_dir.path().join("config-home-without-profile"); + let first_config_dir = temp_dir.path().join("first-config-dir"); + let second_config_dir = temp_dir.path().join("second-config-dir"); + let profile = "xdg-directory-order"; + write_profile(&first_config_dir, profile, "first-directory-marker")?; + write_profile(&second_config_dir, profile, "second-directory-marker")?; + + show_config(profile) + .current_dir(temp_dir.path()) + .env("XDG_CONFIG_HOME", &config_home) + .env( + "XDG_CONFIG_DIRS", + env::join_paths([&first_config_dir, &second_config_dir])?, + ) + .assert() + .success() + .stdout(predicate::str::contains( + "repository = \"first-directory-marker\"", + )) + .stdout(predicate::str::contains("second-directory-marker").not()); + + Ok(()) +} + +#[test] +fn rustic_home_precedes_xdg_config_directories() -> TestResult<()> { + let temp_dir = tempdir()?; + let rustic_home = temp_dir.path().join("rustic-home"); + let config_home = temp_dir.path().join("config-home"); + let config_dir = temp_dir.path().join("config-dir"); + let profile = "rustic-home-precedence"; + write_rustic_home_profile(&rustic_home, profile, "rustic-home-marker")?; + write_profile(&config_home, profile, "xdg-home-marker")?; + write_profile(&config_dir, profile, "xdg-directory-marker")?; + + show_config(profile) + .current_dir(temp_dir.path()) + .env("RUSTIC_HOME", &rustic_home) + .env("XDG_CONFIG_HOME", &config_home) + .env("XDG_CONFIG_DIRS", env::join_paths([&config_dir])?) + .assert() + .success() + .stdout(predicate::str::contains( + "repository = \"rustic-home-marker\"", + )) + .stdout(predicate::str::contains("xdg-home-marker").not()) + .stdout(predicate::str::contains("xdg-directory-marker").not()); + + Ok(()) +} + +#[test] +fn relative_rustic_home_is_ignored() -> TestResult<()> { + let temp_dir = tempdir()?; + let config_home = temp_dir.path().join("config-home"); + let relative_rustic_home = temp_dir.path().join("relative-rustic-home"); + let profile = "relative-rustic-home"; + write_rustic_home_profile(&relative_rustic_home, profile, "relative-marker")?; + write_profile(&config_home, profile, "xdg-home-marker")?; + + show_config(profile) + .current_dir(temp_dir.path()) + .env("RUSTIC_HOME", "relative-rustic-home") + .env("XDG_CONFIG_HOME", &config_home) + .env_remove("XDG_CONFIG_DIRS") + .assert() + .success() + .stdout(predicate::str::contains("repository = \"xdg-home-marker\"")) + .stdout(predicate::str::contains("relative-marker").not()); + + Ok(()) +}