Skip to main content

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}