qframe/storage/
documents.rs1use std::fs;
8use std::path::{Path, PathBuf};
9
10use super::dirs::{absolute, env_lookup};
11
12const USER_DIRS: &str = "user-dirs.dirs";
14
15const DOCUMENTS_KEY: &str = "XDG_DOCUMENTS_DIR";
17
18#[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
43fn 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
60fn documents_line(text: &str, home: Option<&Path>) -> Option<PathBuf> {
68 let named = text.lines().rev().find_map(|line| documents_value(line, home))?;
69 (Some(named.as_path()) != home).then_some(named)
71}
72
73fn 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 Some(_) => None,
90 None => Some(PathBuf::from(value)).filter(|path| path.is_absolute()),
91 }
92}
93
94fn 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 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 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 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 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}