Skip to main content

qframe/storage/
user_dirs.rs

1//! The folders a person keeps their own things in: Desktop, Documents, Downloads, Music, Pictures,
2//! Videos and the rest, the folders a file manager lists under the home.
3//!
4//! Their names are the person's language: `~/Masaüstü` and `~/Belgeler` on a Turkish desktop,
5//! `~/Schreibtisch` and `~/Dokumente` on a German one. Linux desktops write the real names into
6//! `user-dirs.dirs`, so they are read from there rather than guessed.
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use super::dirs::{absolute, env_lookup};
12
13/// The file Linux desktops name the user's folders in, under the config root.
14const USER_DIRS: &str = "user-dirs.dirs";
15
16/// One of the folders the XDG user directories name.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum UserDir {
20    /// The desktop, whose files a desktop environment shows on its background.
21    Desktop,
22    /// Documents: files the person made and wants to find again.
23    Documents,
24    /// Downloads, where a browser saves what it fetches.
25    Downloads,
26    /// Music.
27    Music,
28    /// Pictures.
29    Pictures,
30    /// Files shared with others on the same machine or network.
31    Public,
32    /// Templates a file manager offers for a new file.
33    Templates,
34    /// Videos.
35    Videos,
36}
37
38impl UserDir {
39    /// Every user folder, in the order a file manager usually lists them.
40    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    /// The word in its `user-dirs.dirs` line: `XDG_<word>_DIR`.
52    #[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    /// The folder's name in a home without `user-dirs.dirs`, and on macOS and Windows.
67    #[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/// The person's folder `which`, such as the desktop: on a Turkish desktop `user_dir(UserDir::Desktop)`
83/// is `$HOME/Masaüstü`.
84///
85/// - Linux and other Unix systems: the `XDG_<NAME>_DIR` line of `user-dirs.dirs` in the config
86///   root (`$XDG_CONFIG_HOME` when it is an absolute path, else `$HOME/.config`), which is where a
87///   desktop records the folder's name in the user's language. A missing or unreadable file, a line
88///   that does not follow the format, and a line that names the home folder itself (the way that
89///   file turns a folder off) all give the English name in the home, such as `$HOME/Desktop`.
90/// - macOS: the English name in the home.
91/// - Windows: the Known Folder, which the user can move to another drive; when the system does not
92///   answer, the English name in `%USERPROFILE%`.
93///
94/// `None` when there is no home folder to put it in. A `HOME` that is not an absolute path counts
95/// as missing, as it does for [`config_dir`](super::config_dir). The folder is not created and does
96/// not have to exist. A test uses [`user_dir_in`], which reads no environment.
97#[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/// The folder `which` of the home `home`, as the `user-dirs.dirs` in the config folder `config`
107/// names it, or its English name in `home` when that file does not name it.
108///
109/// For a test, and for an application that already knows the home and config folders it means.
110/// Only the file is read; the environment is not.
111#[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/// The user's Documents folder, where an application puts files the user made and wants to find
120/// again in a file manager: projects, exports, notes. The same as
121/// [`user_dir(UserDir::Documents)`](user_dir).
122#[must_use]
123pub fn documents_dir() -> Option<PathBuf> {
124    user_dir(UserDir::Documents)
125}
126
127/// The system's own answer for `which` on Windows.
128#[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
142/// The folder `which` from variables read through `lookup` and the text of `user-dirs.dirs` read
143/// through `read`, so no test depends on the developer's own desktop.
144fn 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
164/// The folder `which` that `text`, the contents of a `user-dirs.dirs`, names; `None` when it names
165/// none, names it in a way the format does not allow, or turns it off by naming the home folder.
166///
167/// The format is a shell file with one `XDG_<NAME>_DIR="<value>"` line per folder, where the
168/// value is `$HOME` followed by a path, or an absolute path. `#` starts a comment line, a
169/// backslash takes the next character as it is, and nothing else is expanded. As in a shell, the
170/// last valid line wins.
171pub(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    // A folder set to the home folder itself is how the format says the folder is turned off.
175    (Some(named.as_path()) != home).then_some(named)
176}
177
178/// The path one line of `user-dirs.dirs` gives the folder of `key`, if it is that line and it is
179/// valid.
180fn 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        // `$HOMEWORK` is not the home folder, and no other variable is expanded.
194        Some(_) => None,
195        None => Some(PathBuf::from(value)).filter(|path| path.is_absolute()),
196    }
197}
198
199/// `text` with every backslash escape replaced by the character it escapes. A bare `"` inside the
200/// quotes would end the shell word, so such a line is invalid; so is a backslash at the very end.
201fn 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    /// A lookup over a fixed list of variables, so no test reads the developer's own environment.
219    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    /// A reader that finds `text` at `at` and nothing anywhere else.
224    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        // A relative XDG_CONFIG_HOME is ignored, as it is for the settings folder.
263        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        // Without a home folder only an absolute value can name the folder.
324        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}