use std::fs;
use std::path::{Path, PathBuf};
use super::dirs::{absolute, env_lookup};
const USER_DIRS: &str = "user-dirs.dirs";
const DOCUMENTS_KEY: &str = "XDG_DOCUMENTS_DIR";
#[must_use]
pub fn documents_dir() -> Option<PathBuf> {
#[cfg(windows)]
if let Some(known) = dirs::document_dir() {
return Some(known);
}
documents_root(env_lookup, |path| fs::read_to_string(path).ok())
}
fn documents_root(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("Documents"));
}
let home = absolute(non_empty("HOME"));
if cfg!(target_os = "macos") {
return home.map(|home| home.join("Documents"));
}
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| documents_line(&text, home.as_deref()));
named.or_else(|| home.map(|home| home.join("Documents")))
}
fn documents_line(text: &str, home: Option<&Path>) -> Option<PathBuf> {
let named = text.lines().rev().find_map(|line| documents_value(line, home))?;
(Some(named.as_path()) != home).then_some(named)
}
fn documents_value(line: &str, home: Option<&Path>) -> Option<PathBuf> {
let line = line.trim();
if line.starts_with('#') {
return None;
}
let (key, value) = line.split_once('=')?;
if key.trim() != DOCUMENTS_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!(documents_root(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!(documents_root(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!(documents_root(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!(documents_root(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!(documents_line(text, 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!(documents_line("XDG_DOCUMENTS_DIR=\"$HOME\"\n", home), None);
assert_eq!(documents_line("XDG_DOCUMENTS_DIR=\"$HOME/\"\n", home), None);
assert_eq!(documents_line("XDG_DOCUMENTS_DIR=\"/home/ada\"\n", home), None);
let read = file("/home/ada/.config/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"$HOME/\"\n");
assert_eq!(documents_root(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!(documents_line(text, 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!(documents_line(escaped, home), Some(PathBuf::from("/home/ada/My Files\\old \"x\"")));
assert_eq!(documents_line("XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n", None), None);
assert_eq!(documents_line("XDG_DOCUMENTS_DIR=\"/srv/docs\"\n", 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!(
documents_root(env(&[("HOME", "/Users/ada")]), none),
Some(PathBuf::from("/Users/ada/Documents"))
);
}
if cfg!(windows) {
let vars = env(&[("USERPROFILE", r"C:\Users\ada")]);
assert_eq!(documents_root(vars, none), Some(PathBuf::from(r"C:\Users\ada\Documents")));
}
}
#[test]
fn no_home_means_no_folder() {
assert_eq!(documents_root(env(&[]), none), None);
if !cfg!(windows) {
assert_eq!(documents_root(env(&[("HOME", "ada")]), none), None, "a relative home counts as missing");
}
}
}