feat: support "~" expansion on paths in config

This commit is contained in:
Ellis Clayton 2026-04-22 20:33:39 +10:00
commit 27c950dfef
No known key found for this signature in database
GPG key ID: A2A2BF86BEB49FC6

View file

@ -1,8 +1,9 @@
use std::{
borrow::Cow,
env, fmt,
fs::{self, File},
io::{ErrorKind, Write},
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
sync::LazyLock,
time::Duration,
};
@ -589,11 +590,13 @@ impl<'a> Config<'a> {
source: PathSource::EnvVar,
}
} else if let Some(config_value) = &raw_config.directories.cache_dir {
// If the user explicitly configured a cache directory, use that.
// Resolve possible ~ prefixed path
let expanded_path = expand_path(config_value, env::home_dir().as_ref())?.into_owned();
// Resolve possible relative path.
let resolved_path = relative_path_root.join(expanded_path);
PathWithSource {
// Resolve possible relative path. It would be nicer to clean up the path, but Rust stdlib
// does not give any method for that that does not need the paths to exist.
path: relative_path_root.join(config_value),
path: resolved_path,
source: PathSource::ConfigFile,
}
} else if let Ok(default_dir) = get_app_root(AppDataType::UserCache, &crate::APP_INFO) {
@ -610,11 +613,18 @@ impl<'a> Config<'a> {
.directories
.custom_pages_dir
.as_ref()
.map(|path| PathWithSource {
.map(|path| -> Result<PathWithSource> {
// Resolve possible ~ prefixed path
let expanded_path = expand_path(path, env::home_dir().as_ref())?.into_owned();
// Resolve possible relative path.
path: relative_path_root.join(path),
source: PathSource::ConfigFile,
let resolved_path = relative_path_root.join(expanded_path);
Ok(PathWithSource {
path: resolved_path,
source: PathSource::ConfigFile,
})
})
.transpose()?
.or_else(|| {
get_app_root(AppDataType::UserData, &crate::APP_INFO)
.map(|path| {
@ -642,6 +652,36 @@ impl<'a> Config<'a> {
}
}
/// Expands tilde (~) prefixed directories into its absolute version
fn expand_path<'a>(input_path: &'a PathBuf, home_path: Option<&PathBuf>) -> Result<Cow<'a, Path>> {
if input_path.is_absolute() {
return Ok(Cow::Borrowed(input_path));
}
let home_path = home_path.ok_or(anyhow!("Unable to find user home directory"))?;
let mut components = input_path.components();
if let Some(Component::Normal(first_component_raw)) = components.next() {
let first_component = first_component_raw
.to_str()
.ok_or(anyhow!("Path contains invalid UTF-8"))?;
if first_component.starts_with("~") {
match first_component.len() {
1 => {
let rest: PathBuf = components.collect();
let expanded = home_path.join(rest);
return Ok(Cow::Owned(expanded));
}
_ => return Err(anyhow!("Tilde expansion with a login name not supported")),
}
}
}
Ok(Cow::Borrowed(input_path))
}
/// The [`ConfigLoader`] is used to load a [`Config`] from a file.
///
/// Since the rich [`Config`] keeps references to [`RawConfig`], the raw config needs to be kept alive outside of the
@ -790,6 +830,63 @@ mod test {
assert_eq!(raw_config, deserialized);
}
#[test]
fn expand_path_with_valid_home() {
let home = Some(PathBuf::from("/foo/bar"));
let path_to_expand = PathBuf::from("~/baz");
assert_eq!(
*expand_path(&path_to_expand, home.as_ref()).unwrap(),
PathBuf::from("/foo/bar/baz")
);
}
#[test]
fn expand_path_with_absolute_path() {
let home = Some(PathBuf::from("/foo/bar"));
let dir_to_expand = PathBuf::from("/one/two");
assert_eq!(
*expand_path(&dir_to_expand, home.as_ref()).unwrap(),
dir_to_expand
);
}
#[test]
fn error_with_tilde_username() {
let home = Some(PathBuf::from("/foo/bar"));
let dir_to_expand = PathBuf::from("~baz/foo");
assert!(expand_path(&dir_to_expand, home.as_ref()).is_err());
}
#[test]
fn expand_tilde_in_config_file() {
let mut raw_config = RawConfig::default();
raw_config.directories.cache_dir = Some("~/my/custom_cache".into());
raw_config.directories.custom_pages_dir = Some("~/custom_pages".into());
let config = Config::from_raw(
&raw_config,
PathWithSource {
path: PathBuf::from("/path/to/config/config.toml"),
source: PathSource::OsConvention,
},
)
.unwrap();
let home_dir = env::home_dir().unwrap();
assert_eq!(
config.directories.cache_dir.path(),
home_dir.join("my/custom_cache")
);
assert_eq!(
config.directories.custom_pages_dir.unwrap().path(),
home_dir.join("custom_pages")
);
}
#[test]
fn relative_path_resolution() {
let mut raw_config = RawConfig::default();