use std::fs;
use std::path::{Path, PathBuf};
use super::dirs::{absolute, env_lookup};
const USER_DIRS: &str = "user-dirs.dirs";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum UserDir {
Desktop,
Documents,
Downloads,
Music,
Pictures,
Public,
Templates,
Videos,
}
impl UserDir {
pub const ALL: [Self; 8] = [
Self::Desktop,
Self::Documents,
Self::Downloads,
Self::Music,
Self::Pictures,
Self::Videos,
Self::Public,
Self::Templates,
];
#[must_use]
pub fn xdg_name(self) -> &'static str {
match self {
Self::Desktop => "DESKTOP",
Self::Documents => "DOCUMENTS",
Self::Downloads => "DOWNLOAD",
Self::Music => "MUSIC",
Self::Pictures => "PICTURES",
Self::Public => "PUBLICSHARE",
Self::Templates => "TEMPLATES",
Self::Videos => "VIDEOS",
}
}
#[must_use]
pub fn english_name(self) -> &'static str {
match self {
Self::Desktop => "Desktop",
Self::Documents => "Documents",
Self::Downloads => "Downloads",
Self::Music => "Music",
Self::Pictures => "Pictures",
Self::Public => "Public",
Self::Templates => "Templates",
Self::Videos => "Videos",
}
}
}
#[must_use]
pub fn user_dir(which: UserDir) -> Option<PathBuf> {
#[cfg(windows)]
if let Some(known) = known_folder(which) {
return Some(known);
}
user_dir_root(which, env_lookup, |path| fs::read_to_string(path).ok())
}
#[must_use]
pub fn user_dir_in(which: UserDir, home: &Path, config: &Path) -> PathBuf {
fs::read_to_string(config.join(USER_DIRS))
.ok()
.and_then(|text| user_dir_line(&text, which, Some(home)))
.unwrap_or_else(|| home.join(which.english_name()))
}
#[must_use]
pub fn documents_dir() -> Option<PathBuf> {
user_dir(UserDir::Documents)
}
#[cfg(windows)]
fn known_folder(which: UserDir) -> Option<PathBuf> {
match which {
UserDir::Desktop => dirs::desktop_dir(),
UserDir::Documents => dirs::document_dir(),
UserDir::Downloads => dirs::download_dir(),
UserDir::Music => dirs::audio_dir(),
UserDir::Pictures => dirs::picture_dir(),
UserDir::Public => dirs::public_dir(),
UserDir::Templates => dirs::template_dir(),
UserDir::Videos => dirs::video_dir(),
}
}
fn user_dir_root(
which: UserDir,
lookup: impl Fn(&str) -> Option<PathBuf>,
read: impl Fn(&Path) -> Option<String>,
) -> Option<PathBuf> {
let non_empty = |name: &str| lookup(name).filter(|path| !path.as_os_str().is_empty());
if cfg!(windows) {
return absolute(non_empty("USERPROFILE")).map(|home| home.join(which.english_name()));
}
let home = absolute(non_empty("HOME"));
if cfg!(target_os = "macos") {
return home.map(|home| home.join(which.english_name()));
}
let config = absolute(non_empty("XDG_CONFIG_HOME")).or_else(|| home.as_ref().map(|home| home.join(".config")));
let named = config
.and_then(|config| read(&config.join(USER_DIRS)))
.and_then(|text| user_dir_line(&text, which, home.as_deref()));
named.or_else(|| home.map(|home| home.join(which.english_name())))
}
pub(crate) fn user_dir_line(text: &str, which: UserDir, home: Option<&Path>) -> Option<PathBuf> {
let key = format!("XDG_{}_DIR", which.xdg_name());
let named = text.lines().rev().find_map(|line| user_dir_value(line, &key, home))?;
(Some(named.as_path()) != home).then_some(named)
}
fn user_dir_value(line: &str, key: &str, home: Option<&Path>) -> Option<PathBuf> {
let line = line.trim();
if line.starts_with('#') {
return None;
}
let (name, value) = line.split_once('=')?;
if name.trim() != key {
return None;
}
let quoted = value.trim().strip_prefix('"')?.strip_suffix('"')?;
let value = unescape(quoted)?;
match value.strip_prefix("$HOME") {
Some(rest) if rest.is_empty() || rest.starts_with('/') => Some(home?.join(rest.trim_start_matches('/'))),
Some(_) => None,
None => Some(PathBuf::from(value)).filter(|path| path.is_absolute()),
}
}
fn unescape(text: &str) -> Option<String> {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
match c {
'\\' => out.push(chars.next()?),
'"' => return None,
c => out.push(c),
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<PathBuf> {
move |name: &str| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| PathBuf::from(value))
}
fn file(at: &'static str, text: &'static str) -> impl Fn(&Path) -> Option<String> {
move |path: &Path| (path == Path::new(at)).then(|| text.to_owned())
}
fn none(_: &Path) -> Option<String> {
None
}
const HOME: &[(&str, &str)] = &[("HOME", "/home/ada")];
fn linux() -> bool {
cfg!(all(unix, not(target_os = "macos")))
}
#[test]
fn the_desktop_names_the_folder_in_the_users_language() {
if !linux() {
return;
}
let text = "# This file is written by xdg-user-dirs-update\n\
XDG_DESKTOP_DIR=\"$HOME/Masaüstü\"\n\
XDG_DOCUMENTS_DIR=\"$HOME/Belgeler\"\n";
let read = file("/home/ada/.config/user-dirs.dirs", text);
assert_eq!(user_dir_root(UserDir::Documents, env(HOME), read), Some(PathBuf::from("/home/ada/Belgeler")));
}
#[test]
fn the_file_is_read_from_the_xdg_config_root() {
if !linux() {
return;
}
let read = file("/cfg/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"/data/docs\"\n");
let vars = env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "/cfg")]);
assert_eq!(
user_dir_root(UserDir::Documents, vars, read),
Some(PathBuf::from("/data/docs")),
"an absolute value is used as it is"
);
let read = file("/home/ada/.config/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n");
let vars = env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "cfg")]);
assert_eq!(user_dir_root(UserDir::Documents, vars, read), Some(PathBuf::from("/home/ada/Docs")));
}
#[test]
fn a_missing_or_broken_file_falls_back_to_documents() {
if !linux() {
return;
}
let fallback = Some(PathBuf::from("/home/ada/Documents"));
assert_eq!(user_dir_root(UserDir::Documents, env(HOME), none), fallback, "no file");
let broken = [
"",
"XDG_DOCUMENTS_DIR=$HOME/Docs\n",
"XDG_DOCUMENTS_DIR=\"$HOME/Docs\n",
"XDG_DOCUMENTS_DIR=\"Docs\"\n",
"XDG_DOCUMENTS_DIR=\"$HOMEWORK/Docs\"\n",
"XDG_DOCUMENTS_DIR=\"${XDG_DATA_HOME}/Docs\"\n",
"XDG_DOCUMENTS_DIR=\"$HOME/a\"b\"\n",
"XDG_DOCUMENTS_DIR=\"$HOME/Docs\\\"\n",
"# XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n",
"XDG_DOWNLOAD_DIR=\"$HOME/Docs\"\n",
"\u{0}\u{ffff}=== not a shell file",
];
for text in broken {
assert_eq!(user_dir_line(text, UserDir::Documents, Some(Path::new("/home/ada"))), None, "{text:?}");
}
}
#[test]
fn naming_the_home_folder_turns_the_folder_off() {
if !linux() {
return;
}
let home = Some(Path::new("/home/ada"));
assert_eq!(user_dir_line("XDG_DOCUMENTS_DIR=\"$HOME\"\n", UserDir::Documents, home), None);
assert_eq!(user_dir_line("XDG_DOCUMENTS_DIR=\"$HOME/\"\n", UserDir::Documents, home), None);
assert_eq!(user_dir_line("XDG_DOCUMENTS_DIR=\"/home/ada\"\n", UserDir::Documents, home), None);
let read = file("/home/ada/.config/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"$HOME/\"\n");
assert_eq!(user_dir_root(UserDir::Documents, env(HOME), read), Some(PathBuf::from("/home/ada/Documents")));
}
#[test]
fn the_format_is_read_as_a_shell_reads_it() {
if !linux() {
return;
}
let home = Some(Path::new("/home/ada"));
let text = " XDG_DOCUMENTS_DIR = \"$HOME/My\\ Files\\\\old\" \nXDG_DOCUMENTS_DIR=\"$HOME/New\"\nXDG_DOCUMENTS_DIR=broken\n";
assert_eq!(
user_dir_line(text, UserDir::Documents, home),
Some(PathBuf::from("/home/ada/New")),
"the last valid line wins"
);
let escaped = "XDG_DOCUMENTS_DIR=\"$HOME/My\\ Files\\\\old \\\"x\\\"\"\n";
assert_eq!(
user_dir_line(escaped, UserDir::Documents, home),
Some(PathBuf::from("/home/ada/My Files\\old \"x\""))
);
assert_eq!(user_dir_line("XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n", UserDir::Documents, None), None);
assert_eq!(
user_dir_line("XDG_DOCUMENTS_DIR=\"/srv/docs\"\n", UserDir::Documents, None),
Some(PathBuf::from("/srv/docs"))
);
}
#[test]
fn macos_and_windows_use_the_documents_folder_in_the_home() {
if cfg!(target_os = "macos") {
assert_eq!(
user_dir_root(UserDir::Documents, env(&[("HOME", "/Users/ada")]), none),
Some(PathBuf::from("/Users/ada/Documents"))
);
}
if cfg!(windows) {
let vars = env(&[("USERPROFILE", r"C:\Users\ada")]);
assert_eq!(user_dir_root(UserDir::Documents, vars, none), Some(PathBuf::from(r"C:\Users\ada\Documents")));
}
}
#[test]
fn no_home_means_no_folder() {
assert_eq!(user_dir_root(UserDir::Documents, env(&[]), none), None);
if !cfg!(windows) {
assert_eq!(
user_dir_root(UserDir::Documents, env(&[("HOME", "ada")]), none),
None,
"a relative home counts as missing"
);
}
}
#[test]
fn the_desktop_and_every_other_folder_are_found_by_their_own_line() {
if !linux() {
return;
}
let text = "XDG_DESKTOP_DIR=\"$HOME/Masaüstü\"\n\
XDG_DOWNLOAD_DIR=\"$HOME/İndirilenler\"\n\
XDG_DOCUMENTS_DIR=\"$HOME/Belgeler\"\n";
let at = |which| user_dir_root(which, env(HOME), file("/home/ada/.config/user-dirs.dirs", text));
assert_eq!(at(UserDir::Desktop), Some(PathBuf::from("/home/ada/Masaüstü")), "the desktop by its Turkish name");
assert_eq!(at(UserDir::Downloads), Some(PathBuf::from("/home/ada/İndirilenler")));
assert_eq!(at(UserDir::Documents), Some(PathBuf::from("/home/ada/Belgeler")));
assert_eq!(at(UserDir::Music), Some(PathBuf::from("/home/ada/Music")), "a folder the file does not name");
}
#[test]
fn a_folder_is_read_from_the_config_folder_a_test_names() {
let root = std::env::temp_dir().join(format!("qframe-user-dirs-{}", std::process::id()));
let (home, config) = (root.join("home"), root.join("config"));
std::fs::create_dir_all(&config).expect("a config folder");
assert_eq!(user_dir_in(UserDir::Desktop, &home, &config), home.join("Desktop"), "no file: the English name");
std::fs::write(config.join("user-dirs.dirs"), "XDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\n").expect("the file");
assert_eq!(user_dir_in(UserDir::Desktop, &home, &config), home.join("Schreibtisch"));
assert_eq!(user_dir_in(UserDir::Videos, &home, &config), home.join("Videos"));
std::fs::remove_dir_all(root).ok();
}
#[test]
fn every_folder_has_its_own_line_and_name() {
let words: std::collections::BTreeSet<_> = UserDir::ALL.iter().map(|which| which.xdg_name()).collect();
let names: std::collections::BTreeSet<_> = UserDir::ALL.iter().map(|which| which.english_name()).collect();
assert_eq!((words.len(), names.len()), (UserDir::ALL.len(), UserDir::ALL.len()));
}
}