1use std::fs;
9use std::path::{Path, PathBuf};
10
11use super::dirs::{absolute, env_lookup};
12
13const USER_DIRS: &str = "user-dirs.dirs";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum UserDir {
20 Desktop,
22 Documents,
24 Downloads,
26 Music,
28 Pictures,
30 Public,
32 Templates,
34 Videos,
36}
37
38impl UserDir {
39 pub const ALL: [Self; 8] = [
41 Self::Desktop,
42 Self::Documents,
43 Self::Downloads,
44 Self::Music,
45 Self::Pictures,
46 Self::Videos,
47 Self::Public,
48 Self::Templates,
49 ];
50
51 #[must_use]
53 pub fn xdg_name(self) -> &'static str {
54 match self {
55 Self::Desktop => "DESKTOP",
56 Self::Documents => "DOCUMENTS",
57 Self::Downloads => "DOWNLOAD",
58 Self::Music => "MUSIC",
59 Self::Pictures => "PICTURES",
60 Self::Public => "PUBLICSHARE",
61 Self::Templates => "TEMPLATES",
62 Self::Videos => "VIDEOS",
63 }
64 }
65
66 #[must_use]
68 pub fn english_name(self) -> &'static str {
69 match self {
70 Self::Desktop => "Desktop",
71 Self::Documents => "Documents",
72 Self::Downloads => "Downloads",
73 Self::Music => "Music",
74 Self::Pictures => "Pictures",
75 Self::Public => "Public",
76 Self::Templates => "Templates",
77 Self::Videos => "Videos",
78 }
79 }
80}
81
82#[must_use]
98pub fn user_dir(which: UserDir) -> Option<PathBuf> {
99 #[cfg(windows)]
100 if let Some(known) = known_folder(which) {
101 return Some(known);
102 }
103 user_dir_root(which, env_lookup, |path| fs::read_to_string(path).ok())
104}
105
106#[must_use]
112pub fn user_dir_in(which: UserDir, home: &Path, config: &Path) -> PathBuf {
113 fs::read_to_string(config.join(USER_DIRS))
114 .ok()
115 .and_then(|text| user_dir_line(&text, which, Some(home)))
116 .unwrap_or_else(|| home.join(which.english_name()))
117}
118
119#[must_use]
123pub fn documents_dir() -> Option<PathBuf> {
124 user_dir(UserDir::Documents)
125}
126
127#[cfg(windows)]
129fn known_folder(which: UserDir) -> Option<PathBuf> {
130 match which {
131 UserDir::Desktop => dirs::desktop_dir(),
132 UserDir::Documents => dirs::document_dir(),
133 UserDir::Downloads => dirs::download_dir(),
134 UserDir::Music => dirs::audio_dir(),
135 UserDir::Pictures => dirs::picture_dir(),
136 UserDir::Public => dirs::public_dir(),
137 UserDir::Templates => dirs::template_dir(),
138 UserDir::Videos => dirs::video_dir(),
139 }
140}
141
142fn user_dir_root(
145 which: UserDir,
146 lookup: impl Fn(&str) -> Option<PathBuf>,
147 read: impl Fn(&Path) -> Option<String>,
148) -> Option<PathBuf> {
149 let non_empty = |name: &str| lookup(name).filter(|path| !path.as_os_str().is_empty());
150 if cfg!(windows) {
151 return absolute(non_empty("USERPROFILE")).map(|home| home.join(which.english_name()));
152 }
153 let home = absolute(non_empty("HOME"));
154 if cfg!(target_os = "macos") {
155 return home.map(|home| home.join(which.english_name()));
156 }
157 let config = absolute(non_empty("XDG_CONFIG_HOME")).or_else(|| home.as_ref().map(|home| home.join(".config")));
158 let named = config
159 .and_then(|config| read(&config.join(USER_DIRS)))
160 .and_then(|text| user_dir_line(&text, which, home.as_deref()));
161 named.or_else(|| home.map(|home| home.join(which.english_name())))
162}
163
164pub(crate) fn user_dir_line(text: &str, which: UserDir, home: Option<&Path>) -> Option<PathBuf> {
172 let key = format!("XDG_{}_DIR", which.xdg_name());
173 let named = text.lines().rev().find_map(|line| user_dir_value(line, &key, home))?;
174 (Some(named.as_path()) != home).then_some(named)
176}
177
178fn user_dir_value(line: &str, key: &str, home: Option<&Path>) -> Option<PathBuf> {
181 let line = line.trim();
182 if line.starts_with('#') {
183 return None;
184 }
185 let (name, value) = line.split_once('=')?;
186 if name.trim() != key {
187 return None;
188 }
189 let quoted = value.trim().strip_prefix('"')?.strip_suffix('"')?;
190 let value = unescape(quoted)?;
191 match value.strip_prefix("$HOME") {
192 Some(rest) if rest.is_empty() || rest.starts_with('/') => Some(home?.join(rest.trim_start_matches('/'))),
193 Some(_) => None,
195 None => Some(PathBuf::from(value)).filter(|path| path.is_absolute()),
196 }
197}
198
199fn unescape(text: &str) -> Option<String> {
202 let mut out = String::with_capacity(text.len());
203 let mut chars = text.chars();
204 while let Some(c) = chars.next() {
205 match c {
206 '\\' => out.push(chars.next()?),
207 '"' => return None,
208 c => out.push(c),
209 }
210 }
211 Some(out)
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<PathBuf> {
220 move |name: &str| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| PathBuf::from(value))
221 }
222
223 fn file(at: &'static str, text: &'static str) -> impl Fn(&Path) -> Option<String> {
225 move |path: &Path| (path == Path::new(at)).then(|| text.to_owned())
226 }
227
228 fn none(_: &Path) -> Option<String> {
229 None
230 }
231
232 const HOME: &[(&str, &str)] = &[("HOME", "/home/ada")];
233
234 fn linux() -> bool {
235 cfg!(all(unix, not(target_os = "macos")))
236 }
237
238 #[test]
239 fn the_desktop_names_the_folder_in_the_users_language() {
240 if !linux() {
241 return;
242 }
243 let text = "# This file is written by xdg-user-dirs-update\n\
244 XDG_DESKTOP_DIR=\"$HOME/Masaüstü\"\n\
245 XDG_DOCUMENTS_DIR=\"$HOME/Belgeler\"\n";
246 let read = file("/home/ada/.config/user-dirs.dirs", text);
247 assert_eq!(user_dir_root(UserDir::Documents, env(HOME), read), Some(PathBuf::from("/home/ada/Belgeler")));
248 }
249
250 #[test]
251 fn the_file_is_read_from_the_xdg_config_root() {
252 if !linux() {
253 return;
254 }
255 let read = file("/cfg/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"/data/docs\"\n");
256 let vars = env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "/cfg")]);
257 assert_eq!(
258 user_dir_root(UserDir::Documents, vars, read),
259 Some(PathBuf::from("/data/docs")),
260 "an absolute value is used as it is"
261 );
262 let read = file("/home/ada/.config/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n");
264 let vars = env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "cfg")]);
265 assert_eq!(user_dir_root(UserDir::Documents, vars, read), Some(PathBuf::from("/home/ada/Docs")));
266 }
267
268 #[test]
269 fn a_missing_or_broken_file_falls_back_to_documents() {
270 if !linux() {
271 return;
272 }
273 let fallback = Some(PathBuf::from("/home/ada/Documents"));
274 assert_eq!(user_dir_root(UserDir::Documents, env(HOME), none), fallback, "no file");
275 let broken = [
276 "",
277 "XDG_DOCUMENTS_DIR=$HOME/Docs\n",
278 "XDG_DOCUMENTS_DIR=\"$HOME/Docs\n",
279 "XDG_DOCUMENTS_DIR=\"Docs\"\n",
280 "XDG_DOCUMENTS_DIR=\"$HOMEWORK/Docs\"\n",
281 "XDG_DOCUMENTS_DIR=\"${XDG_DATA_HOME}/Docs\"\n",
282 "XDG_DOCUMENTS_DIR=\"$HOME/a\"b\"\n",
283 "XDG_DOCUMENTS_DIR=\"$HOME/Docs\\\"\n",
284 "# XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n",
285 "XDG_DOWNLOAD_DIR=\"$HOME/Docs\"\n",
286 "\u{0}\u{ffff}=== not a shell file",
287 ];
288 for text in broken {
289 assert_eq!(user_dir_line(text, UserDir::Documents, Some(Path::new("/home/ada"))), None, "{text:?}");
290 }
291 }
292
293 #[test]
294 fn naming_the_home_folder_turns_the_folder_off() {
295 if !linux() {
296 return;
297 }
298 let home = Some(Path::new("/home/ada"));
299 assert_eq!(user_dir_line("XDG_DOCUMENTS_DIR=\"$HOME\"\n", UserDir::Documents, home), None);
300 assert_eq!(user_dir_line("XDG_DOCUMENTS_DIR=\"$HOME/\"\n", UserDir::Documents, home), None);
301 assert_eq!(user_dir_line("XDG_DOCUMENTS_DIR=\"/home/ada\"\n", UserDir::Documents, home), None);
302 let read = file("/home/ada/.config/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"$HOME/\"\n");
303 assert_eq!(user_dir_root(UserDir::Documents, env(HOME), read), Some(PathBuf::from("/home/ada/Documents")));
304 }
305
306 #[test]
307 fn the_format_is_read_as_a_shell_reads_it() {
308 if !linux() {
309 return;
310 }
311 let home = Some(Path::new("/home/ada"));
312 let text = " XDG_DOCUMENTS_DIR = \"$HOME/My\\ Files\\\\old\" \nXDG_DOCUMENTS_DIR=\"$HOME/New\"\nXDG_DOCUMENTS_DIR=broken\n";
313 assert_eq!(
314 user_dir_line(text, UserDir::Documents, home),
315 Some(PathBuf::from("/home/ada/New")),
316 "the last valid line wins"
317 );
318 let escaped = "XDG_DOCUMENTS_DIR=\"$HOME/My\\ Files\\\\old \\\"x\\\"\"\n";
319 assert_eq!(
320 user_dir_line(escaped, UserDir::Documents, home),
321 Some(PathBuf::from("/home/ada/My Files\\old \"x\""))
322 );
323 assert_eq!(user_dir_line("XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n", UserDir::Documents, None), None);
325 assert_eq!(
326 user_dir_line("XDG_DOCUMENTS_DIR=\"/srv/docs\"\n", UserDir::Documents, None),
327 Some(PathBuf::from("/srv/docs"))
328 );
329 }
330
331 #[test]
332 fn macos_and_windows_use_the_documents_folder_in_the_home() {
333 if cfg!(target_os = "macos") {
334 assert_eq!(
335 user_dir_root(UserDir::Documents, env(&[("HOME", "/Users/ada")]), none),
336 Some(PathBuf::from("/Users/ada/Documents"))
337 );
338 }
339 if cfg!(windows) {
340 let vars = env(&[("USERPROFILE", r"C:\Users\ada")]);
341 assert_eq!(user_dir_root(UserDir::Documents, vars, none), Some(PathBuf::from(r"C:\Users\ada\Documents")));
342 }
343 }
344
345 #[test]
346 fn no_home_means_no_folder() {
347 assert_eq!(user_dir_root(UserDir::Documents, env(&[]), none), None);
348 if !cfg!(windows) {
349 assert_eq!(
350 user_dir_root(UserDir::Documents, env(&[("HOME", "ada")]), none),
351 None,
352 "a relative home counts as missing"
353 );
354 }
355 }
356
357 #[test]
358 fn the_desktop_and_every_other_folder_are_found_by_their_own_line() {
359 if !linux() {
360 return;
361 }
362 let text = "XDG_DESKTOP_DIR=\"$HOME/Masaüstü\"\n\
363 XDG_DOWNLOAD_DIR=\"$HOME/İndirilenler\"\n\
364 XDG_DOCUMENTS_DIR=\"$HOME/Belgeler\"\n";
365 let at = |which| user_dir_root(which, env(HOME), file("/home/ada/.config/user-dirs.dirs", text));
366 assert_eq!(at(UserDir::Desktop), Some(PathBuf::from("/home/ada/Masaüstü")), "the desktop by its Turkish name");
367 assert_eq!(at(UserDir::Downloads), Some(PathBuf::from("/home/ada/İndirilenler")));
368 assert_eq!(at(UserDir::Documents), Some(PathBuf::from("/home/ada/Belgeler")));
369 assert_eq!(at(UserDir::Music), Some(PathBuf::from("/home/ada/Music")), "a folder the file does not name");
370 }
371
372 #[test]
373 fn a_folder_is_read_from_the_config_folder_a_test_names() {
374 let root = std::env::temp_dir().join(format!("qframe-user-dirs-{}", std::process::id()));
375 let (home, config) = (root.join("home"), root.join("config"));
376 std::fs::create_dir_all(&config).expect("a config folder");
377 assert_eq!(user_dir_in(UserDir::Desktop, &home, &config), home.join("Desktop"), "no file: the English name");
378 std::fs::write(config.join("user-dirs.dirs"), "XDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\n").expect("the file");
379 assert_eq!(user_dir_in(UserDir::Desktop, &home, &config), home.join("Schreibtisch"));
380 assert_eq!(user_dir_in(UserDir::Videos, &home, &config), home.join("Videos"));
381 std::fs::remove_dir_all(root).ok();
382 }
383
384 #[test]
385 fn every_folder_has_its_own_line_and_name() {
386 let words: std::collections::BTreeSet<_> = UserDir::ALL.iter().map(|which| which.xdg_name()).collect();
387 let names: std::collections::BTreeSet<_> = UserDir::ALL.iter().map(|which| which.english_name()).collect();
388 assert_eq!((words.len(), names.len()), (UserDir::ALL.len(), UserDir::ALL.len()));
389 }
390}