Skip to main content

qframe/storage/
dirs.rs

1//! Where an application keeps its settings, its own data, its state and its cache.
2//!
3//! Settings and data are two different folders. A theme choice is a setting; a recorded session
4//! is data. On Linux and other Unix systems they are two separate XDG folders, on Windows the
5//! roaming and the local folder, and on macOS the same folder, because macOS has no split
6//! between the two.
7//!
8//! State and cache are two more. State is what an application remembers between runs that is
9//! worth keeping but not worth carrying to another machine: the result of the last background
10//! check, a window's last tab. A cache can be deleted at any time and rebuilt. Each has its own
11//! XDG folder on Linux and other Unix systems, so a user can back up one and clear the other.
12
13use std::path::PathBuf;
14
15/// Where application `app` keeps its settings.
16///
17/// - Linux and other Unix systems: `$XDG_CONFIG_HOME/<app>` when `XDG_CONFIG_HOME` is an
18///   absolute path, else `$HOME/.config/<app>`.
19/// - macOS: `$HOME/Library/Application Support/<app>`.
20/// - Windows: `%APPDATA%\<app>`, the roaming folder, so the settings follow the user between
21///   machines.
22///
23/// `None` when the platform's variables say nothing: there is no home directory to write in,
24/// and the application has to keep its settings in memory. A `HOME` that is not an absolute
25/// path counts as missing, as a relative XDG variable does: it would put the files under
26/// whatever folder the application happened to be started in. The folder is not created and does
27/// not have to exist.
28#[must_use]
29pub fn config_dir(app: &str) -> Option<PathBuf> {
30    config_root(env_lookup).map(|root| root.join(app))
31}
32
33/// Where application `app` keeps its own data: records, caches it wants to survive, files it
34/// wrote itself. Records are not settings, so they do not live next to them.
35///
36/// - Linux and other Unix systems: `$XDG_DATA_HOME/<app>` when `XDG_DATA_HOME` is an absolute
37///   path, else `$HOME/.local/share/<app>`.
38/// - macOS: `$HOME/Library/Application Support/<app>`, the same folder as the settings. macOS
39///   has no separate data folder for a command line application, and the framework does not
40///   invent one.
41/// - Windows: `%LOCALAPPDATA%\<app>`, the local folder, so records are not copied between
42///   machines by a roaming profile.
43///
44/// `None` when the platform's variables say nothing, and when `HOME` is not an absolute path.
45/// The folder is not created and does not have to exist.
46#[must_use]
47pub fn data_dir(app: &str) -> Option<PathBuf> {
48    data_root(env_lookup).map(|root| root.join(app))
49}
50
51/// Where application `app` keeps its state: what it remembers between runs that is worth keeping
52/// but does not belong to the user's settings or records, such as the result of its last
53/// background check or the tab it was last on.
54///
55/// - Linux and other Unix systems: `$XDG_STATE_HOME/<app>` when `XDG_STATE_HOME` is an absolute
56///   path, else `$HOME/.local/state/<app>`.
57/// - macOS: `$HOME/Library/Application Support/<app>`, the same folder as the data, since macOS
58///   has no separate state folder.
59/// - Windows: `%LOCALAPPDATA%\<app>`, the local folder, so state stays on the machine it
60///   describes.
61///
62/// `None` when the platform's variables say nothing, and when `HOME` is not an absolute path.
63/// The folder is not created and does not have to exist.
64#[must_use]
65pub fn state_dir(app: &str) -> Option<PathBuf> {
66    state_root(env_lookup).map(|root| root.join(app))
67}
68
69/// Where application `app` keeps its cache: files it can rebuild at any time, which the user or
70/// the system may delete without losing anything.
71///
72/// - Linux and other Unix systems: `$XDG_CACHE_HOME/<app>` when `XDG_CACHE_HOME` is an absolute
73///   path, else `$HOME/.cache/<app>`.
74/// - macOS: `$HOME/Library/Caches/<app>`.
75/// - Windows: `%LOCALAPPDATA%\<app>`, the local folder, so a cache is never copied between
76///   machines by a roaming profile.
77///
78/// `None` when the platform's variables say nothing, and when `HOME` is not an absolute path.
79/// The folder is not created and does not have to exist.
80#[must_use]
81pub fn cache_dir(app: &str) -> Option<PathBuf> {
82    cache_root(env_lookup).map(|root| root.join(app))
83}
84
85/// Reads one environment variable as a path.
86pub(super) fn env_lookup(name: &str) -> Option<PathBuf> {
87    std::env::var_os(name).map(PathBuf::from)
88}
89
90/// The folder settings of every application live under, from variables read through `lookup`.
91pub(super) fn config_root(lookup: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
92    let non_empty = |name: &str| lookup(name).filter(|path| !path.as_os_str().is_empty());
93    if cfg!(windows) {
94        return non_empty("APPDATA");
95    }
96    if cfg!(target_os = "macos") {
97        return absolute(non_empty("HOME")).map(|home| home.join("Library").join("Application Support"));
98    }
99    absolute(non_empty("XDG_CONFIG_HOME")).or_else(|| absolute(non_empty("HOME")).map(|home| home.join(".config")))
100}
101
102/// The folder data of every application lives under, from variables read through `lookup`.
103fn data_root(lookup: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
104    let non_empty = |name: &str| lookup(name).filter(|path| !path.as_os_str().is_empty());
105    if cfg!(windows) {
106        return non_empty("LOCALAPPDATA");
107    }
108    if cfg!(target_os = "macos") {
109        return absolute(non_empty("HOME")).map(|home| home.join("Library").join("Application Support"));
110    }
111    absolute(non_empty("XDG_DATA_HOME"))
112        .or_else(|| absolute(non_empty("HOME")).map(|home| home.join(".local").join("share")))
113}
114
115/// The folder the state of every application lives under, from variables read through `lookup`.
116pub(super) fn state_root(lookup: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
117    let non_empty = |name: &str| lookup(name).filter(|path| !path.as_os_str().is_empty());
118    if cfg!(windows) {
119        return non_empty("LOCALAPPDATA");
120    }
121    if cfg!(target_os = "macos") {
122        return absolute(non_empty("HOME")).map(|home| home.join("Library").join("Application Support"));
123    }
124    absolute(non_empty("XDG_STATE_HOME"))
125        .or_else(|| absolute(non_empty("HOME")).map(|home| home.join(".local").join("state")))
126}
127
128/// The folder the cache of every application lives under, from variables read through `lookup`.
129pub(super) fn cache_root(lookup: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
130    let non_empty = |name: &str| lookup(name).filter(|path| !path.as_os_str().is_empty());
131    if cfg!(windows) {
132        return non_empty("LOCALAPPDATA");
133    }
134    if cfg!(target_os = "macos") {
135        return absolute(non_empty("HOME")).map(|home| home.join("Library").join("Caches"));
136    }
137    absolute(non_empty("XDG_CACHE_HOME")).or_else(|| absolute(non_empty("HOME")).map(|home| home.join(".cache")))
138}
139
140/// A variable counts only when it holds an absolute path. The XDG specification says a relative
141/// one is invalid and must be ignored; a relative `HOME` is no better, since it would put the
142/// files under the working directory.
143pub(super) fn absolute(path: Option<PathBuf>) -> Option<PathBuf> {
144    path.filter(|path| path.is_absolute())
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    /// A lookup over a fixed list of variables, so no test reads the developer's own environment.
152    fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<PathBuf> {
153        move |name: &str| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| PathBuf::from(value))
154    }
155
156    #[test]
157    fn settings_and_data_are_two_folders_on_unix() {
158        if !cfg!(all(unix, not(target_os = "macos"))) {
159            return;
160        }
161        let home = env(&[("HOME", "/home/ada")]);
162        assert_eq!(config_root(&home), Some(PathBuf::from("/home/ada/.config")));
163        assert_eq!(data_root(&home), Some(PathBuf::from("/home/ada/.local/share")));
164
165        let xdg = env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "/cfg"), ("XDG_DATA_HOME", "/dat")]);
166        assert_eq!(config_root(&xdg), Some(PathBuf::from("/cfg")));
167        assert_eq!(data_root(&xdg), Some(PathBuf::from("/dat")));
168
169        // A relative XDG path is invalid and ignored, in both folders.
170        let relative = env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "cfg"), ("XDG_DATA_HOME", "dat")]);
171        assert_eq!(config_root(&relative), Some(PathBuf::from("/home/ada/.config")));
172        assert_eq!(data_root(&relative), Some(PathBuf::from("/home/ada/.local/share")));
173
174        // An empty variable is as good as unset.
175        let empty = env(&[("HOME", "/home/ada"), ("XDG_DATA_HOME", "")]);
176        assert_eq!(data_root(&empty), Some(PathBuf::from("/home/ada/.local/share")));
177    }
178
179    #[test]
180    fn macos_keeps_both_in_application_support() {
181        if !cfg!(target_os = "macos") {
182            return;
183        }
184        let home = env(&[("HOME", "/Users/ada")]);
185        let support = PathBuf::from("/Users/ada/Library/Application Support");
186        assert_eq!(config_root(&home), Some(support.clone()));
187        assert_eq!(data_root(&home), Some(support));
188    }
189
190    #[test]
191    fn windows_roams_settings_and_keeps_data_local() {
192        if !cfg!(windows) {
193            return;
194        }
195        let both =
196            env(&[("APPDATA", r"C:\Users\ada\AppData\Roaming"), ("LOCALAPPDATA", r"C:\Users\ada\AppData\Local")]);
197        assert_eq!(config_root(&both), Some(PathBuf::from(r"C:\Users\ada\AppData\Roaming")));
198        assert_eq!(data_root(&both), Some(PathBuf::from(r"C:\Users\ada\AppData\Local")));
199    }
200
201    #[test]
202    fn a_relative_home_counts_as_missing() {
203        if cfg!(windows) {
204            return;
205        }
206        let relative = env(&[("HOME", "ada")]);
207        assert_eq!(config_root(&relative), None, "not a folder under the working directory");
208        assert_eq!(data_root(&relative), None);
209        // An absolute XDG folder still stands on its own.
210        if cfg!(not(target_os = "macos")) {
211            let xdg = env(&[("HOME", "ada"), ("XDG_CONFIG_HOME", "/cfg"), ("XDG_DATA_HOME", "/dat")]);
212            assert_eq!(config_root(&xdg), Some(PathBuf::from("/cfg")));
213            assert_eq!(data_root(&xdg), Some(PathBuf::from("/dat")));
214        }
215    }
216
217    #[test]
218    fn state_and_cache_follow_their_xdg_variables_on_unix() {
219        if !cfg!(all(unix, not(target_os = "macos"))) {
220            return;
221        }
222        let home = env(&[("HOME", "/home/ada")]);
223        assert_eq!(state_root(&home), Some(PathBuf::from("/home/ada/.local/state")));
224        assert_eq!(cache_root(&home), Some(PathBuf::from("/home/ada/.cache")));
225
226        let xdg = env(&[("HOME", "/home/ada"), ("XDG_STATE_HOME", "/st"), ("XDG_CACHE_HOME", "/ca")]);
227        assert_eq!(state_root(&xdg), Some(PathBuf::from("/st")));
228        assert_eq!(cache_root(&xdg), Some(PathBuf::from("/ca")));
229
230        // A relative XDG path is invalid and ignored, as for the other two folders.
231        let relative = env(&[("HOME", "/home/ada"), ("XDG_STATE_HOME", "st"), ("XDG_CACHE_HOME", "ca")]);
232        assert_eq!(state_root(&relative), Some(PathBuf::from("/home/ada/.local/state")));
233        assert_eq!(cache_root(&relative), Some(PathBuf::from("/home/ada/.cache")));
234
235        let empty = env(&[("HOME", "/home/ada"), ("XDG_STATE_HOME", ""), ("XDG_CACHE_HOME", "")]);
236        assert_eq!(state_root(&empty), Some(PathBuf::from("/home/ada/.local/state")));
237        assert_eq!(cache_root(&empty), Some(PathBuf::from("/home/ada/.cache")));
238
239        // A relative HOME counts as missing, but an absolute XDG folder stands on its own.
240        let bare = env(&[("HOME", "ada")]);
241        assert_eq!((state_root(&bare), cache_root(&bare)), (None, None));
242        let only_xdg = env(&[("HOME", "ada"), ("XDG_STATE_HOME", "/st"), ("XDG_CACHE_HOME", "/ca")]);
243        assert_eq!(state_root(&only_xdg), Some(PathBuf::from("/st")));
244        assert_eq!(cache_root(&only_xdg), Some(PathBuf::from("/ca")));
245    }
246
247    #[test]
248    fn macos_keeps_state_with_the_data_and_caches_in_library_caches() {
249        if !cfg!(target_os = "macos") {
250            return;
251        }
252        let home = env(&[("HOME", "/Users/ada")]);
253        assert_eq!(state_root(&home), Some(PathBuf::from("/Users/ada/Library/Application Support")));
254        assert_eq!(cache_root(&home), Some(PathBuf::from("/Users/ada/Library/Caches")));
255    }
256
257    #[test]
258    fn windows_keeps_state_and_cache_local() {
259        if !cfg!(windows) {
260            return;
261        }
262        let both =
263            env(&[("APPDATA", r"C:\Users\ada\AppData\Roaming"), ("LOCALAPPDATA", r"C:\Users\ada\AppData\Local")]);
264        assert_eq!(state_root(&both), Some(PathBuf::from(r"C:\Users\ada\AppData\Local")));
265        assert_eq!(cache_root(&both), Some(PathBuf::from(r"C:\Users\ada\AppData\Local")));
266    }
267
268    #[test]
269    fn nothing_in_the_environment_means_no_folder() {
270        assert_eq!(config_root(env(&[])), None);
271        assert_eq!(data_root(env(&[])), None);
272        assert_eq!(state_root(env(&[])), None);
273        assert_eq!(cache_root(env(&[])), None);
274    }
275
276    #[test]
277    fn the_application_name_is_the_last_segment() {
278        // Whatever the platform, every public function end in the application's own folder.
279        for dir in
280            [config_dir("qfocus"), data_dir("qfocus"), state_dir("qfocus"), cache_dir("qfocus")].into_iter().flatten()
281        {
282            assert_eq!(dir.file_name().and_then(|name| name.to_str()), Some("qfocus"), "{}", dir.display());
283            assert!(dir.is_absolute(), "{}", dir.display());
284        }
285    }
286}