qframe/storage/family.rs
1//! One settings folder for a family 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 family'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 family'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 family's
18//! and the application's titles: `~/Documents/Quvyta/Code`.
19
20use std::path::{Path, PathBuf};
21
22use super::dirs::{cache_root, config_root, env_lookup, state_root};
23use super::documents::documents_dir;
24use super::migrate::{self, Migration};
25
26/// The extension every settings file of a family carries.
27const EXTENSION: &str = "conf";
28
29/// A family 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::Family;
38///
39/// let family = Family::QUVYTA;
40/// if let (Some(folder), Some(file)) = (family.config_dir(), family.app_file("code")) {
41/// assert_eq!(file, folder.join("code.conf"));
42/// }
43/// ```
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct Family {
46 id: &'static str,
47 title: &'static str,
48}
49
50impl Family {
51 /// The Quvyta family: `~/.config/quvyta` on Linux and other Unix systems,
52 /// `~/Library/Application Support/Quvyta` on macOS, `%APPDATA%\Quvyta` on Windows.
53 pub const QUVYTA: Family = Family::new("quvyta", "Quvyta");
54
55 /// A family 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 family'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 family'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 family 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 family 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 family'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 family keeps its state, such as the result of its last
112 /// background check: `<state folder>/<family>/<app>`.
113 ///
114 /// - Linux and other Unix systems: `$XDG_STATE_HOME/<id>/<app>` when `XDG_STATE_HOME` is an
115 /// absolute path, else `$HOME/.local/state/<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 [`state_dir`](super::state_dir). Not created.
120 #[must_use]
121 pub fn state_dir(&self, app: &str) -> Option<PathBuf> {
122 self.member_under(state_root(env_lookup), app)
123 }
124
125 /// Where application `app` of the family keeps files it can rebuild at any time:
126 /// `<cache folder>/<family>/<app>`.
127 ///
128 /// - Linux and other Unix systems: `$XDG_CACHE_HOME/<id>/<app>` when `XDG_CACHE_HOME` is an
129 /// absolute path, else `$HOME/.cache/<id>/<app>`.
130 /// - macOS: `$HOME/Library/Caches/<title>/<app>`.
131 /// - Windows: `%LOCALAPPDATA%\<title>\<app>`.
132 ///
133 /// `None` when there is no home folder, as for [`cache_dir`](super::cache_dir). Not created.
134 #[must_use]
135 pub fn cache_dir(&self, app: &str) -> Option<PathBuf> {
136 self.member_under(cache_root(env_lookup), app)
137 }
138
139 /// Where the work the user makes with an application is kept by default:
140 /// `<documents>/<family title>/<app_title>`, such as `~/Documents/Quvyta/Code`, in the
141 /// [Documents folder](super::documents_dir) under the name the user's desktop gave it.
142 /// `app_title` is the application's name as the user reads it. `None` when there is no home
143 /// folder. Not created.
144 #[must_use]
145 pub fn workspace_dir(&self, app_title: &str) -> Option<PathBuf> {
146 documents_dir().map(|documents| self.workspace_under(&documents, app_title))
147 }
148
149 /// Moves the settings of application `app` from the folder it used before it joined the
150 /// family into the family's layout, once, without losing anything.
151 ///
152 /// `legacy_dir/settings.toml` becomes [`app_file`](Self::app_file); every other file under
153 /// `legacy_dir`, at any depth, goes to the same place under [`app_dir`](Self::app_dir). When
154 /// `legacy_dir` already is the application's folder, only `settings.toml` moves and the other
155 /// files stay where they are. Call it at start, before
156 /// [`Settings::load_member`](super::Settings::load_member).
157 ///
158 /// Every file is copied first, with its permissions, then read back and compared, and only
159 /// then removed from the old place. A file whose new place is already taken stays where it
160 /// is, and so do symbolic links, which are never followed; nothing is overwritten or merged.
161 /// Old folders left empty are removed, from the deepest up; a folder with anything left in it
162 /// is kept. A missing `legacy_dir` is nothing to do, so calling it again after a finished move
163 /// changes nothing. Whatever stayed behind is in the report, with the reason.
164 ///
165 /// A crash in the middle of a move can leave the new file empty next to the old one; the old
166 /// one is still whole, and the next call reports the pair instead of choosing between them.
167 #[must_use]
168 pub fn adopt(&self, app: &str, legacy_dir: &Path) -> Migration {
169 match self.config_dir() {
170 Some(config_dir) => self.adopt_in(&config_dir, app, legacy_dir),
171 None => Migration::without_folder(),
172 }
173 }
174
175 /// [`adopt`](Self::adopt) into `config_dir` as the family's folder instead of this
176 /// platform's, for a test or a demo that must leave the user's own settings alone: the
177 /// settings become `<config_dir>/<app>.conf` and the other files move under
178 /// `<config_dir>/<app>`.
179 #[must_use]
180 pub fn adopt_in(&self, config_dir: &Path, app: &str, legacy_dir: &Path) -> Migration {
181 migrate::adopt(legacy_dir, &config_dir.join(file_name(app)), &config_dir.join(app))
182 }
183
184 /// The folder of member `app` in the family's folder under `root`.
185 fn member_under(&self, root: Option<PathBuf>, app: &str) -> Option<PathBuf> {
186 self.config_under(root).map(|family| family.join(app))
187 }
188
189 /// The family's folder under the `root` of this platform, config or any other.
190 fn config_under(&self, root: Option<PathBuf>) -> Option<PathBuf> {
191 // macOS and Windows show these folders by their names, so they are written as names are.
192 let name = if cfg!(any(windows, target_os = "macos")) { self.title } else { self.id };
193 root.map(|root| root.join(name))
194 }
195
196 /// The workspace of `app_title` under the Documents folder `documents`.
197 fn workspace_under(&self, documents: &Path, app_title: &str) -> PathBuf {
198 documents.join(self.title).join(app_title)
199 }
200}
201
202/// The settings file name of `id`.
203pub(super) fn file_name(id: &str) -> String {
204 format!("{id}.{EXTENSION}")
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 /// A lookup over a fixed list of variables, so no test reads the developer's own environment.
212 fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<PathBuf> {
213 move |name: &str| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| PathBuf::from(value))
214 }
215
216 #[test]
217 fn the_family_folder_is_lowercase_on_unix() {
218 if !cfg!(all(unix, not(target_os = "macos"))) {
219 return;
220 }
221 let family = Family::QUVYTA;
222 let home = config_root(env(&[("HOME", "/home/ada")]));
223 assert_eq!(family.config_under(home), Some(PathBuf::from("/home/ada/.config/quvyta")));
224 let xdg = config_root(env(&[("HOME", "/home/ada"), ("XDG_CONFIG_HOME", "/cfg")]));
225 assert_eq!(family.config_under(xdg), Some(PathBuf::from("/cfg/quvyta")));
226 }
227
228 #[test]
229 fn the_family_folder_carries_the_title_on_macos_and_windows() {
230 if cfg!(target_os = "macos") {
231 let root = config_root(env(&[("HOME", "/Users/ada")]));
232 let expected = PathBuf::from("/Users/ada/Library/Application Support/Quvyta");
233 assert_eq!(Family::QUVYTA.config_under(root), Some(expected));
234 }
235 if cfg!(windows) {
236 let root = config_root(env(&[("APPDATA", r"C:\Users\ada\AppData\Roaming")]));
237 let expected = PathBuf::from(r"C:\Users\ada\AppData\Roaming\Quvyta");
238 assert_eq!(Family::QUVYTA.config_under(root), Some(expected));
239 }
240 }
241
242 #[test]
243 fn a_member_keeps_its_state_and_cache_under_the_family_folder() {
244 if !cfg!(all(unix, not(target_os = "macos"))) {
245 return;
246 }
247 let family = Family::QUVYTA;
248 let home = env(&[("HOME", "/home/ada")]);
249 assert_eq!(
250 family.member_under(state_root(&home), "packages"),
251 Some(PathBuf::from("/home/ada/.local/state/quvyta/packages"))
252 );
253 assert_eq!(
254 family.member_under(cache_root(&home), "packages"),
255 Some(PathBuf::from("/home/ada/.cache/quvyta/packages"))
256 );
257 let xdg = env(&[("HOME", "/home/ada"), ("XDG_STATE_HOME", "/st"), ("XDG_CACHE_HOME", "/ca")]);
258 assert_eq!(family.member_under(state_root(&xdg), "packages"), Some(PathBuf::from("/st/quvyta/packages")));
259 assert_eq!(family.member_under(cache_root(&xdg), "packages"), Some(PathBuf::from("/ca/quvyta/packages")));
260 let relative = env(&[("HOME", "/home/ada"), ("XDG_CACHE_HOME", "ca")]);
261 assert_eq!(
262 family.member_under(cache_root(&relative), "packages"),
263 Some(PathBuf::from("/home/ada/.cache/quvyta/packages"))
264 );
265 assert_eq!(family.member_under(cache_root(env(&[])), "packages"), None);
266 }
267
268 #[test]
269 fn the_public_state_and_cache_folders_end_in_family_and_member() {
270 let family = Family::QUVYTA;
271 for dir in [family.state_dir("packages"), family.cache_dir("packages")].into_iter().flatten() {
272 assert!(
273 dir.ends_with(Path::new(family.id()).join("packages"))
274 || dir.ends_with(Path::new(family.title()).join("packages")),
275 "{}",
276 dir.display()
277 );
278 }
279 }
280
281 #[test]
282 fn no_home_means_no_family_folder() {
283 assert_eq!(Family::QUVYTA.config_under(config_root(env(&[]))), None);
284 }
285
286 #[test]
287 fn files_are_lowercase_ids_in_the_family_folder() {
288 let family = Family::new("tools", "Tools");
289 let Some(dir) = family.config_dir() else { return };
290 assert_eq!(family.shared_file(), Some(dir.join("tools.conf")));
291 assert_eq!(family.app_file("code"), Some(dir.join("code.conf")));
292 assert_eq!(family.app_dir("code"), Some(dir.join("code")));
293 assert_eq!((family.id(), family.title()), ("tools", "Tools"));
294 }
295
296 #[test]
297 fn the_workspace_is_the_family_and_app_titles_under_documents() {
298 let documents = Path::new("/home/ada/Belgeler");
299 let expected = PathBuf::from("/home/ada/Belgeler/Quvyta/Code");
300 assert_eq!(Family::QUVYTA.workspace_under(documents, "Code"), expected);
301 if let (Some(documents), Some(workspace)) = (documents_dir(), Family::QUVYTA.workspace_dir("Code")) {
302 assert_eq!(workspace, documents.join("Quvyta").join("Code"));
303 }
304 }
305}