Skip to main content

qframe/storage/
documents.rs

1//! Where the user keeps their own documents, the folder a file manager calls Documents.
2//!
3//! The name of that folder is the user's language: `~/Belgeler` on a Turkish desktop,
4//! `~/Dokumente` on a German one. Linux desktops write the real name into `user-dirs.dirs`, so
5//! it is read from there rather than guessed.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use super::dirs::{absolute, env_lookup};
11
12/// The file Linux desktops name the user's folders in, under the config root.
13const USER_DIRS: &str = "user-dirs.dirs";
14
15/// The line of [`USER_DIRS`] that names the Documents folder.
16const DOCUMENTS_KEY: &str = "XDG_DOCUMENTS_DIR";
17
18/// The user's Documents folder, where an application puts files the user made and wants to find
19/// again in a file manager: projects, exports, notes.
20///
21/// - Linux and other Unix systems: the `XDG_DOCUMENTS_DIR` line of `user-dirs.dirs` in the
22///   config root (`$XDG_CONFIG_HOME` when it is an absolute path, else `$HOME/.config`), which
23///   is where a desktop records the folder's name in the user's language, such as
24///   `$HOME/Belgeler`. A missing or unreadable file, a line that does not follow the format, and
25///   a line that names the home folder itself (the way that file turns a folder off) all give
26///   `$HOME/Documents`.
27/// - macOS: `$HOME/Documents`.
28/// - Windows: the Documents Known Folder, which the user can move to another drive; when the
29///   system does not answer, `%USERPROFILE%\Documents`.
30///
31/// `None` when there is no home folder to put it in. A `HOME` that is not an absolute path
32/// counts as missing, as it does for [`config_dir`](super::config_dir). The folder is not created
33/// and does not have to exist.
34#[must_use]
35pub fn documents_dir() -> Option<PathBuf> {
36    #[cfg(windows)]
37    if let Some(known) = dirs::document_dir() {
38        return Some(known);
39    }
40    documents_root(env_lookup, |path| fs::read_to_string(path).ok())
41}
42
43/// The Documents folder from variables read through `lookup` and the text of `user-dirs.dirs`
44/// read through `read`, so no test depends on the developer's own desktop.
45fn documents_root(lookup: impl Fn(&str) -> Option<PathBuf>, read: impl Fn(&Path) -> Option<String>) -> Option<PathBuf> {
46    let non_empty = |name: &str| lookup(name).filter(|path| !path.as_os_str().is_empty());
47    if cfg!(windows) {
48        return absolute(non_empty("USERPROFILE")).map(|home| home.join("Documents"));
49    }
50    let home = absolute(non_empty("HOME"));
51    if cfg!(target_os = "macos") {
52        return home.map(|home| home.join("Documents"));
53    }
54    let config = absolute(non_empty("XDG_CONFIG_HOME")).or_else(|| home.as_ref().map(|home| home.join(".config")));
55    let named =
56        config.and_then(|config| read(&config.join(USER_DIRS))).and_then(|text| documents_line(&text, home.as_deref()));
57    named.or_else(|| home.map(|home| home.join("Documents")))
58}
59
60/// The Documents folder `text`, the contents of a `user-dirs.dirs`, names; `None` when it names
61/// none, names it in a way the format does not allow, or turns it off by naming the home folder.
62///
63/// The format is a shell file with one `XDG_<NAME>_DIR="<value>"` line per folder, where the
64/// value is `$HOME` followed by a path, or an absolute path. `#` starts a comment line, a
65/// backslash takes the next character as it is, and nothing else is expanded. As in a shell, the
66/// last valid line wins.
67fn documents_line(text: &str, home: Option<&Path>) -> Option<PathBuf> {
68    let named = text.lines().rev().find_map(|line| documents_value(line, home))?;
69    // A folder set to the home folder itself is how the format says the folder is turned off.
70    (Some(named.as_path()) != home).then_some(named)
71}
72
73/// The path one line of `user-dirs.dirs` gives the Documents folder, if it is that line and it
74/// is valid.
75fn documents_value(line: &str, home: Option<&Path>) -> Option<PathBuf> {
76    let line = line.trim();
77    if line.starts_with('#') {
78        return None;
79    }
80    let (key, value) = line.split_once('=')?;
81    if key.trim() != DOCUMENTS_KEY {
82        return None;
83    }
84    let quoted = value.trim().strip_prefix('"')?.strip_suffix('"')?;
85    let value = unescape(quoted)?;
86    match value.strip_prefix("$HOME") {
87        Some(rest) if rest.is_empty() || rest.starts_with('/') => Some(home?.join(rest.trim_start_matches('/'))),
88        // `$HOMEWORK` is not the home folder, and no other variable is expanded.
89        Some(_) => None,
90        None => Some(PathBuf::from(value)).filter(|path| path.is_absolute()),
91    }
92}
93
94/// `text` with every backslash escape replaced by the character it escapes. A bare `"` inside the
95/// quotes would end the shell word, so such a line is invalid; so is a backslash at the very end.
96fn unescape(text: &str) -> Option<String> {
97    let mut out = String::with_capacity(text.len());
98    let mut chars = text.chars();
99    while let Some(c) = chars.next() {
100        match c {
101            '\\' => out.push(chars.next()?),
102            '"' => return None,
103            c => out.push(c),
104        }
105    }
106    Some(out)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    /// A lookup over a fixed list of variables, so no test reads the developer's own environment.
114    fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<PathBuf> {
115        move |name: &str| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| PathBuf::from(value))
116    }
117
118    /// A reader that finds `text` at `at` and nothing anywhere else.
119    fn file(at: &'static str, text: &'static str) -> impl Fn(&Path) -> Option<String> {
120        move |path: &Path| (path == Path::new(at)).then(|| text.to_owned())
121    }
122
123    fn none(_: &Path) -> Option<String> {
124        None
125    }
126
127    const HOME: &[(&str, &str)] = &[("HOME", "/home/ada")];
128
129    fn linux() -> bool {
130        cfg!(all(unix, not(target_os = "macos")))
131    }
132
133    #[test]
134    fn the_desktop_names_the_folder_in_the_users_language() {
135        if !linux() {
136            return;
137        }
138        let text = "# This file is written by xdg-user-dirs-update\n\
139                    XDG_DESKTOP_DIR=\"$HOME/Masaüstü\"\n\
140                    XDG_DOCUMENTS_DIR=\"$HOME/Belgeler\"\n";
141        let read = file("/home/ada/.config/user-dirs.dirs", text);
142        assert_eq!(documents_root(env(HOME), read), Some(PathBuf::from("/home/ada/Belgeler")));
143    }
144
145    #[test]
146    fn the_file_is_read_from_the_xdg_config_root() {
147        if !linux() {
148            return;
149        }
150        let read = file("/cfg/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"/data/docs\"\n");
151        let vars = env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "/cfg")]);
152        assert_eq!(documents_root(vars, read), Some(PathBuf::from("/data/docs")), "an absolute value is used as it is");
153        // A relative XDG_CONFIG_HOME is ignored, as it is for the settings folder.
154        let read = file("/home/ada/.config/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n");
155        let vars = env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "cfg")]);
156        assert_eq!(documents_root(vars, read), Some(PathBuf::from("/home/ada/Docs")));
157    }
158
159    #[test]
160    fn a_missing_or_broken_file_falls_back_to_documents() {
161        if !linux() {
162            return;
163        }
164        let fallback = Some(PathBuf::from("/home/ada/Documents"));
165        assert_eq!(documents_root(env(HOME), none), fallback, "no file");
166        let broken = [
167            "",
168            "XDG_DOCUMENTS_DIR=$HOME/Docs\n",
169            "XDG_DOCUMENTS_DIR=\"$HOME/Docs\n",
170            "XDG_DOCUMENTS_DIR=\"Docs\"\n",
171            "XDG_DOCUMENTS_DIR=\"$HOMEWORK/Docs\"\n",
172            "XDG_DOCUMENTS_DIR=\"${XDG_DATA_HOME}/Docs\"\n",
173            "XDG_DOCUMENTS_DIR=\"$HOME/a\"b\"\n",
174            "XDG_DOCUMENTS_DIR=\"$HOME/Docs\\\"\n",
175            "# XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n",
176            "XDG_DOWNLOAD_DIR=\"$HOME/Docs\"\n",
177            "\u{0}\u{ffff}=== not a shell file",
178        ];
179        for text in broken {
180            assert_eq!(documents_line(text, Some(Path::new("/home/ada"))), None, "{text:?}");
181        }
182    }
183
184    #[test]
185    fn naming_the_home_folder_turns_the_folder_off() {
186        if !linux() {
187            return;
188        }
189        let home = Some(Path::new("/home/ada"));
190        assert_eq!(documents_line("XDG_DOCUMENTS_DIR=\"$HOME\"\n", home), None);
191        assert_eq!(documents_line("XDG_DOCUMENTS_DIR=\"$HOME/\"\n", home), None);
192        assert_eq!(documents_line("XDG_DOCUMENTS_DIR=\"/home/ada\"\n", home), None);
193        let read = file("/home/ada/.config/user-dirs.dirs", "XDG_DOCUMENTS_DIR=\"$HOME/\"\n");
194        assert_eq!(documents_root(env(HOME), read), Some(PathBuf::from("/home/ada/Documents")));
195    }
196
197    #[test]
198    fn the_format_is_read_as_a_shell_reads_it() {
199        if !linux() {
200            return;
201        }
202        let home = Some(Path::new("/home/ada"));
203        let text = "  XDG_DOCUMENTS_DIR = \"$HOME/My\\ Files\\\\old\"  \nXDG_DOCUMENTS_DIR=\"$HOME/New\"\nXDG_DOCUMENTS_DIR=broken\n";
204        assert_eq!(documents_line(text, home), Some(PathBuf::from("/home/ada/New")), "the last valid line wins");
205        let escaped = "XDG_DOCUMENTS_DIR=\"$HOME/My\\ Files\\\\old \\\"x\\\"\"\n";
206        assert_eq!(documents_line(escaped, home), Some(PathBuf::from("/home/ada/My Files\\old \"x\"")));
207        // Without a home folder only an absolute value can name the folder.
208        assert_eq!(documents_line("XDG_DOCUMENTS_DIR=\"$HOME/Docs\"\n", None), None);
209        assert_eq!(documents_line("XDG_DOCUMENTS_DIR=\"/srv/docs\"\n", None), Some(PathBuf::from("/srv/docs")));
210    }
211
212    #[test]
213    fn macos_and_windows_use_the_documents_folder_in_the_home() {
214        if cfg!(target_os = "macos") {
215            assert_eq!(
216                documents_root(env(&[("HOME", "/Users/ada")]), none),
217                Some(PathBuf::from("/Users/ada/Documents"))
218            );
219        }
220        if cfg!(windows) {
221            let vars = env(&[("USERPROFILE", r"C:\Users\ada")]);
222            assert_eq!(documents_root(vars, none), Some(PathBuf::from(r"C:\Users\ada\Documents")));
223        }
224    }
225
226    #[test]
227    fn no_home_means_no_folder() {
228        assert_eq!(documents_root(env(&[]), none), None);
229        if !cfg!(windows) {
230            assert_eq!(documents_root(env(&[("HOME", "ada")]), none), None, "a relative home counts as missing");
231        }
232    }
233}