qframe/storage/ecosystem.rs
1//! One settings folder for an ecosystem of applications.
2//!
3//! Applications made to be used together keep their settings side by side, so a user finds all
4//! of them in one place and a setting they share is written once:
5//!
6//! ```text
7//! ~/.config/quvyta/
8//! quvyta.conf the ecosystem's shared settings
9//! code.conf the settings of the application `code`
10//! code/ its other configuration files
11//! ```
12//!
13//! What a member remembers between runs and what it can rebuild sit in the ecosystem's folder under
14//! the platform's state and cache folders, one folder per member: `~/.local/state/quvyta/code`,
15//! `~/.cache/quvyta/code`.
16//!
17//! The work the user makes with an application lives in the Documents folder, under the ecosystem's
18//! and the application's titles: `~/Documents/Quvyta/Code`.
19
20use std::path::{Path, PathBuf};
21
22use super::dirs::{cache_root, config_root, data_root, env_lookup, state_root};
23use super::migrate::{self, Migration};
24use super::user_dirs::documents_dir;
25
26/// The extension every settings file of an ecosystem carries.
27const EXTENSION: &str = "conf";
28
29/// An ecosystem of applications that share one settings folder.
30///
31/// The `id` names the folder and the shared file where names are lowercase by custom, on Linux
32/// and other Unix systems; the `title` names the folder where a user sees it written like a
33/// name, on macOS and Windows, and always in the Documents folder. File names are always the
34/// lowercase id: `quvyta.conf`, `code.conf`.
35///
36/// ```
37/// use qframe::storage::Ecosystem;
38///
39/// let ecosystem = Ecosystem::QUVYTA;
40/// if let (Some(folder), Some(file)) = (ecosystem.config_dir(), ecosystem.app_file("code")) {
41/// assert_eq!(file, folder.join("code.conf"));
42/// }
43/// ```
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct Ecosystem {
46 id: &'static str,
47 title: &'static str,
48}
49
50impl Ecosystem {
51 /// The Quvyta ecosystem: `~/.config/quvyta` on Linux and other Unix systems,
52 /// `~/Library/Application Support/Quvyta` on macOS, `%APPDATA%\Quvyta` on Windows.
53 pub const QUVYTA: Ecosystem = Ecosystem::new("quvyta", "Quvyta");
54
55 /// An ecosystem with a lowercase `id` for folder and file names, such as `quvyta`, and a `title`
56 /// for the places a user reads it as a name, such as `Quvyta`.
57 #[must_use]
58 pub const fn new(id: &'static str, title: &'static str) -> Self {
59 Self { id, title }
60 }
61
62 /// The lowercase name of the ecosystem's folder on Linux and other Unix systems and of its
63 /// shared file.
64 #[must_use]
65 pub fn id(&self) -> &'static str {
66 self.id
67 }
68
69 /// The ecosystem's name as a user reads it.
70 #[must_use]
71 pub fn title(&self) -> &'static str {
72 self.title
73 }
74
75 /// The folder every settings file of the ecosystem lives in:
76 ///
77 /// - Linux and other Unix systems: `$XDG_CONFIG_HOME/<id>` when `XDG_CONFIG_HOME` is an
78 /// absolute path, else `$HOME/.config/<id>`.
79 /// - macOS: `$HOME/Library/Application Support/<title>`.
80 /// - Windows: `%APPDATA%\<title>`, the roaming folder.
81 ///
82 /// `None` when there is no home folder, as for [`config_dir`](super::config_dir). The folder
83 /// is not created.
84 #[must_use]
85 pub fn config_dir(&self) -> Option<PathBuf> {
86 self.config_under(config_root(env_lookup))
87 }
88
89 /// The settings every application of the ecosystem shares: `<config_dir>/<id>.conf`, such as
90 /// `quvyta.conf`.
91 #[must_use]
92 pub fn shared_file(&self) -> Option<PathBuf> {
93 self.config_dir().map(|dir| dir.join(file_name(self.id)))
94 }
95
96 /// The settings file of application `app`: `<config_dir>/<app>.conf`, such as `code.conf`.
97 /// `app` is the application's lowercase id. An application whose id is the ecosystem's own
98 /// would get the [shared file](Self::shared_file), so give it another id.
99 #[must_use]
100 pub fn app_file(&self, app: &str) -> Option<PathBuf> {
101 self.config_dir().map(|dir| dir.join(file_name(app)))
102 }
103
104 /// The folder for the other configuration files of application `app`, next to its settings
105 /// file: `<config_dir>/<app>`. Not created.
106 #[must_use]
107 pub fn app_dir(&self, app: &str) -> Option<PathBuf> {
108 self.config_dir().map(|dir| dir.join(app))
109 }
110
111 /// Where application `app` of the ecosystem keeps the records the person makes with it and
112 /// would miss if they were gone, such as favourites or a history: `<data folder>/<ecosystem>/<app>`.
113 ///
114 /// - Linux and other Unix systems: `$XDG_DATA_HOME/<id>/<app>` when `XDG_DATA_HOME` is an
115 /// absolute path, else `$HOME/.local/share/<id>/<app>`.
116 /// - macOS: `$HOME/Library/Application Support/<title>/<app>`.
117 /// - Windows: `%LOCALAPPDATA%\<title>\<app>`.
118 ///
119 /// `None` when there is no home folder, as for [`data_dir`](super::data_dir). Not created.
120 #[must_use]
121 pub fn data_dir(&self, app: &str) -> Option<PathBuf> {
122 self.member_under(data_root(env_lookup), app)
123 }
124
125 /// Where application `app` of the ecosystem keeps its state, such as the result of its last
126 /// background check: `<state folder>/<ecosystem>/<app>`.
127 ///
128 /// - Linux and other Unix systems: `$XDG_STATE_HOME/<id>/<app>` when `XDG_STATE_HOME` is an
129 /// absolute path, else `$HOME/.local/state/<id>/<app>`.
130 /// - macOS: `$HOME/Library/Application Support/<title>/<app>`.
131 /// - Windows: `%LOCALAPPDATA%\<title>\<app>`.
132 ///
133 /// `None` when there is no home folder, as for [`state_dir`](super::state_dir). Not created.
134 #[must_use]
135 pub fn state_dir(&self, app: &str) -> Option<PathBuf> {
136 self.member_under(state_root(env_lookup), app)
137 }
138
139 /// Where application `app` of the ecosystem keeps files it can rebuild at any time:
140 /// `<cache folder>/<ecosystem>/<app>`.
141 ///
142 /// - Linux and other Unix systems: `$XDG_CACHE_HOME/<id>/<app>` when `XDG_CACHE_HOME` is an
143 /// absolute path, else `$HOME/.cache/<id>/<app>`.
144 /// - macOS: `$HOME/Library/Caches/<title>/<app>`.
145 /// - Windows: `%LOCALAPPDATA%\<title>\<app>`.
146 ///
147 /// `None` when there is no home folder, as for [`cache_dir`](super::cache_dir). Not created.
148 #[must_use]
149 pub fn cache_dir(&self, app: &str) -> Option<PathBuf> {
150 self.member_under(cache_root(env_lookup), app)
151 }
152
153 /// Where the work the user makes with an application is kept by default:
154 /// `<documents>/<ecosystem title>/<app_title>`, such as `~/Documents/Quvyta/Code`, in the
155 /// [Documents folder](super::documents_dir) under the name the user's desktop gave it.
156 /// `app_title` is the application's name as the user reads it. `None` when there is no home
157 /// folder. Not created.
158 #[must_use]
159 pub fn workspace_dir(&self, app_title: &str) -> Option<PathBuf> {
160 documents_dir().map(|documents| self.workspace_under(&documents, app_title))
161 }
162
163 /// Moves the settings of application `app` from the folder it used before it joined the
164 /// ecosystem into the ecosystem's layout, once, without losing anything.
165 ///
166 /// `legacy_dir/settings.toml` becomes [`app_file`](Self::app_file); every other file under
167 /// `legacy_dir`, at any depth, goes to the same place under [`app_dir`](Self::app_dir). When
168 /// `legacy_dir` already is the application's folder, only `settings.toml` moves and the other
169 /// files stay where they are. Call it at start, before
170 /// [`Settings::load_member`](super::Settings::load_member).
171 ///
172 /// Every file is copied first, with its permissions, then read back and compared, and only
173 /// then removed from the old place. A file whose new place is already taken stays where it
174 /// is, and so do symbolic links, which are never followed; nothing is overwritten or merged.
175 /// Old folders left empty are removed, from the deepest up; a folder with anything left in it
176 /// is kept. A missing `legacy_dir` is nothing to do, so calling it again after a finished move
177 /// changes nothing. Whatever stayed behind is in the report, with the reason.
178 ///
179 /// A crash in the middle of a move can leave the new file empty next to the old one; the old
180 /// one is still whole, and the next call reports the pair instead of choosing between them.
181 #[must_use]
182 pub fn adopt(&self, app: &str, legacy_dir: &Path) -> Migration {
183 match self.config_dir() {
184 Some(config_dir) => self.adopt_in(&config_dir, app, legacy_dir),
185 None => Migration::without_folder(),
186 }
187 }
188
189 /// [`adopt`](Self::adopt) into `config_dir` as the ecosystem's folder instead of this
190 /// platform's, for a test or a demo that must leave the user's own settings alone: the
191 /// settings become `<config_dir>/<app>.conf` and the other files move under
192 /// `<config_dir>/<app>`.
193 #[must_use]
194 pub fn adopt_in(&self, config_dir: &Path, app: &str, legacy_dir: &Path) -> Migration {
195 migrate::adopt(legacy_dir, &config_dir.join(file_name(app)), &config_dir.join(app))
196 }
197
198 /// The folder of member `app` in the ecosystem's folder under `root`.
199 fn member_under(&self, root: Option<PathBuf>, app: &str) -> Option<PathBuf> {
200 self.config_under(root).map(|ecosystem| ecosystem.join(app))
201 }
202
203 /// The ecosystem's folder under the `root` of this platform, config or any other.
204 fn config_under(&self, root: Option<PathBuf>) -> Option<PathBuf> {
205 // macOS and Windows show these folders by their names, so they are written as names are.
206 let name = if cfg!(any(windows, target_os = "macos")) { self.title } else { self.id };
207 root.map(|root| root.join(name))
208 }
209
210 /// The workspace of `app_title` under the Documents folder `documents`.
211 fn workspace_under(&self, documents: &Path, app_title: &str) -> PathBuf {
212 documents.join(self.title).join(app_title)
213 }
214}
215
216/// The settings file name of `id`.
217pub(super) fn file_name(id: &str) -> String {
218 format!("{id}.{EXTENSION}")
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 /// A lookup over a fixed list of variables, so no test reads the developer's own environment.
226 fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<PathBuf> {
227 move |name: &str| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| PathBuf::from(value))
228 }
229
230 #[test]
231 fn the_ecosystem_folder_is_lowercase_on_unix() {
232 if !cfg!(all(unix, not(target_os = "macos"))) {
233 return;
234 }
235 let ecosystem = Ecosystem::QUVYTA;
236 let home = config_root(env(&[("HOME", "/home/ada")]));
237 assert_eq!(ecosystem.config_under(home), Some(PathBuf::from("/home/ada/.config/quvyta")));
238 let xdg = config_root(env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "/cfg")]));
239 assert_eq!(ecosystem.config_under(xdg), Some(PathBuf::from("/cfg/quvyta")));
240 }
241
242 #[test]
243 fn the_ecosystem_folder_carries_the_title_on_macos_and_windows() {
244 if cfg!(target_os = "macos") {
245 let root = config_root(env(&[("HOME", "/Users/ada")]));
246 let expected = PathBuf::from("/Users/ada/Library/Application Support/Quvyta");
247 assert_eq!(Ecosystem::QUVYTA.config_under(root), Some(expected));
248 }
249 if cfg!(windows) {
250 let root = config_root(env(&[("APPDATA", r"C:\Users\ada\AppData\Roaming")]));
251 let expected = PathBuf::from(r"C:\Users\ada\AppData\Roaming\Quvyta");
252 assert_eq!(Ecosystem::QUVYTA.config_under(root), Some(expected));
253 }
254 }
255
256 #[test]
257 fn a_member_keeps_its_data_state_and_cache_under_the_ecosystem_folder() {
258 if !cfg!(all(unix, not(target_os = "macos"))) {
259 return;
260 }
261 let ecosystem = Ecosystem::QUVYTA;
262 let home = env(&[("HOME", "/home/ada")]);
263 assert_eq!(
264 ecosystem.member_under(state_root(&home), "packages"),
265 Some(PathBuf::from("/home/ada/.local/state/quvyta/packages"))
266 );
267 assert_eq!(
268 ecosystem.member_under(cache_root(&home), "packages"),
269 Some(PathBuf::from("/home/ada/.cache/quvyta/packages"))
270 );
271 let xdg = env(&[("HOME", "/home/ada"), ("XDG_STATE_HOME", "/st"), ("XDG_CACHE_HOME", "/ca")]);
272 assert_eq!(ecosystem.member_under(state_root(&xdg), "packages"), Some(PathBuf::from("/st/quvyta/packages")));
273 assert_eq!(ecosystem.member_under(cache_root(&xdg), "packages"), Some(PathBuf::from("/ca/quvyta/packages")));
274 let relative = env(&[("HOME", "/home/ada"), ("XDG_CACHE_HOME", "ca")]);
275 assert_eq!(
276 ecosystem.member_under(cache_root(&relative), "packages"),
277 Some(PathBuf::from("/home/ada/.cache/quvyta/packages"))
278 );
279 assert_eq!(ecosystem.member_under(cache_root(env(&[])), "packages"), None);
280 assert_eq!(
281 ecosystem.member_under(data_root(&home), "explorer"),
282 Some(PathBuf::from("/home/ada/.local/share/quvyta/explorer"))
283 );
284 let data = env(&[("HOME", "/home/ada"), ("XDG_DATA_HOME", "/da")]);
285 assert_eq!(ecosystem.member_under(data_root(&data), "explorer"), Some(PathBuf::from("/da/quvyta/explorer")));
286 let relative_data = env(&[("HOME", "/home/ada"), ("XDG_DATA_HOME", "da")]);
287 assert_eq!(
288 ecosystem.member_under(data_root(&relative_data), "explorer"),
289 Some(PathBuf::from("/home/ada/.local/share/quvyta/explorer"))
290 );
291 assert_eq!(ecosystem.member_under(data_root(env(&[("HOME", "home/ada")])), "explorer"), None);
292 }
293
294 #[test]
295 fn the_public_state_and_cache_folders_end_in_ecosystem_and_member() {
296 let ecosystem = Ecosystem::QUVYTA;
297 for dir in [ecosystem.state_dir("packages"), ecosystem.cache_dir("packages")].into_iter().flatten() {
298 assert!(
299 dir.ends_with(Path::new(ecosystem.id()).join("packages"))
300 || dir.ends_with(Path::new(ecosystem.title()).join("packages")),
301 "{}",
302 dir.display()
303 );
304 }
305 }
306
307 #[test]
308 fn no_home_means_no_ecosystem_folder() {
309 assert_eq!(Ecosystem::QUVYTA.config_under(config_root(env(&[]))), None);
310 }
311
312 #[test]
313 fn files_are_lowercase_ids_in_the_ecosystem_folder() {
314 let ecosystem = Ecosystem::new("tools", "Tools");
315 let Some(dir) = ecosystem.config_dir() else { return };
316 assert_eq!(ecosystem.shared_file(), Some(dir.join("tools.conf")));
317 assert_eq!(ecosystem.app_file("code"), Some(dir.join("code.conf")));
318 assert_eq!(ecosystem.app_dir("code"), Some(dir.join("code")));
319 assert_eq!((ecosystem.id(), ecosystem.title()), ("tools", "Tools"));
320 }
321
322 #[test]
323 fn the_workspace_is_the_ecosystem_and_app_titles_under_documents() {
324 let documents = Path::new("/home/ada/Belgeler");
325 let expected = PathBuf::from("/home/ada/Belgeler/Quvyta/Code");
326 assert_eq!(Ecosystem::QUVYTA.workspace_under(documents, "Code"), expected);
327 if let (Some(documents), Some(workspace)) = (documents_dir(), Ecosystem::QUVYTA.workspace_dir("Code")) {
328 assert_eq!(workspace, documents.join("Quvyta").join("Code"));
329 }
330 }
331}