Skip to main content

qframe/storage/
settle.rs

1//! The one look at an application's own file that turns shared values written there as fixed ones
2//! back into following the ecosystem.
3
4use std::fs;
5use std::io;
6use std::path::Path;
7
8use super::ecosystem::file_name;
9use super::preferences::hold_folder;
10use super::{Ecosystem, SettingValue, Settings, Shared};
11use crate::diagnostics::Severity;
12use crate::icons::IconMode;
13
14impl Ecosystem {
15    /// Looks once at the shared keys application `app` wrote into its own file as fixed values,
16    /// and makes the application follow the ecosystem again where nothing is lost by it.
17    ///
18    /// For an application that used to write a choice meant for every member into its own file
19    /// alone. For each of language, theme, icons and reduced motion that its file names as a value
20    /// of its own:
21    ///
22    /// - the same value as the [shared file](Self::shared_file) holds: the key becomes the
23    ///   ecosystem's id, so the application follows the shared value from now on;
24    /// - another value: it stays, since it is what the person chose for this application.
25    ///
26    /// Reduced motion was each application's own before it was shared, so a shared file may not
27    /// hold it yet; it then counts as off, the value every application reads from such a file. An
28    /// application that kept motion follows the ecosystem again, one that reduced it keeps that.
29    ///
30    /// Then the file takes [`Settings::SHARED_CHECKED`]` = true` and every later call changes
31    /// nothing. The mark sits in the application's own file, beside the values it speaks for, so it
32    /// travels with them when the settings folder is copied to another machine. Without it, a
33    /// person who later chose "only here" for the value the ecosystem happens to share would find
34    /// the choice undone at the next start. A missing file is created holding the mark alone: an
35    /// application that starts without one writes its shared keys the new way, and nothing it
36    /// writes afterwards is a leftover to look at. A [`Setup`](crate::widgets::Setup) still counts
37    /// such a file as no settings at all.
38    ///
39    /// Call it at start, before the application's settings are loaded
40    /// ([`Settings::load_member`]), so what it loads already holds the change; a copy loaded
41    /// earlier would save the old values back. The file is read right before it is written and
42    /// only the changed keys and the mark change in it, with the ecosystem's folder held by an
43    /// advisory lock on Unix, as [`set`](Self::set) does. Comments do not survive a change, as with
44    /// [`Settings::save`]. The shared file is only read.
45    ///
46    /// Returns the keys that now follow the ecosystem, in the order of [`Shared::ALL`]; empty when
47    /// nothing changed or the look was already taken.
48    ///
49    /// # Errors
50    ///
51    /// Returns an error of kind [`io::ErrorKind::NotFound`] when there is no home folder, of kind
52    /// [`io::ErrorKind::InvalidData`] when the application's file cannot be read as settings, which
53    /// is then left exactly as it was, and any error from writing.
54    pub fn settle(&self, app: &str) -> io::Result<Vec<Shared>> {
55        match self.config_dir() {
56            Some(dir) => self.settle_in(&dir, app),
57            None => Err(io::Error::new(io::ErrorKind::NotFound, "no config directory found")),
58        }
59    }
60
61    /// [`settle`](Self::settle) with `config_dir` as the ecosystem's folder instead of this
62    /// platform's, for a test or a demo that must leave the user's own files alone.
63    ///
64    /// ```
65    /// use qframe::storage::{Ecosystem, Shared};
66    ///
67    /// # let folder = std::env::temp_dir().join(format!("quvyta-settle-doc-{}", std::process::id()));
68    /// # std::fs::create_dir_all(&folder).expect("folder");
69    /// std::fs::write(folder.join("quvyta.conf"), "theme = \"nordic\"\nlanguage = \"tr\"\n").expect("shared");
70    /// std::fs::write(folder.join("desktop.conf"), "theme = \"nordic\"\nlanguage = \"en\"\n").expect("own");
71    /// let now_following = Ecosystem::QUVYTA.settle_in(&folder, "desktop").expect("settle");
72    /// assert_eq!(now_following, [Shared::Theme]);
73    /// let own = std::fs::read_to_string(folder.join("desktop.conf")).expect("read");
74    /// assert_eq!(own, "theme = \"quvyta\"\nlanguage = \"en\"\nshared-checked = true\n");
75    /// # std::fs::remove_dir_all(&folder).ok();
76    /// ```
77    ///
78    /// # Errors
79    ///
80    /// As [`settle`](Self::settle), except that there is always a folder.
81    pub fn settle_in(&self, config_dir: &Path, app: &str) -> io::Result<Vec<Shared>> {
82        fs::create_dir_all(config_dir)?;
83        let _held = hold_folder(config_dir)?;
84        let mut own = Settings::open(config_dir.join(file_name(app))).member_of(self);
85        if let Some(problem) = own.diagnostics().iter().find(|problem| problem.severity == Severity::Error) {
86            return Err(io::Error::new(io::ErrorKind::InvalidData, problem.to_string()));
87        }
88        if own.get::<bool>(Settings::SHARED_CHECKED) == Some(true) {
89            return Ok(Vec::new());
90        }
91        let shared_path = config_dir.join(file_name(self.id()));
92        let shared = shared_path.is_file().then(|| Settings::open(&shared_path));
93        let mut following = Vec::new();
94        for key in Shared::ALL {
95            let fixed = key.read(&own).filter(|text| *text != self.id());
96            let common = shared
97                .as_ref()
98                .and_then(|shared| key.read(shared))
99                .or_else(|| (key == Shared::ReducedMotion && shared.is_some()).then(|| false.to_string()));
100            if let (Some(fixed), Some(common)) = (fixed, common)
101                && common != self.id()
102                && same(key, &fixed, &common)
103            {
104                own.store(key.key(), SettingValue::Text(self.id().to_owned()));
105                following.push(key);
106            }
107        }
108        own.store(Settings::SHARED_CHECKED, SettingValue::Bool(true));
109        own.save()?;
110        Ok(following)
111    }
112}
113
114/// Whether `fixed` and `common` are one value of `key`: icon modes by the mode they name, the rest
115/// as written, apart from the spaces around them.
116fn same(key: Shared, fixed: &str, common: &str) -> bool {
117    match key {
118        Shared::Icons => IconMode::from_name(fixed).is_some_and(|mode| IconMode::from_name(common) == Some(mode)),
119        Shared::ReducedMotion => fixed.parse::<bool>().is_ok_and(|reduced| common.parse() == Ok(reduced)),
120        Shared::Language | Shared::Theme => {
121            let fixed = fixed.trim();
122            !fixed.is_empty() && fixed == common.trim()
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use std::path::PathBuf;
130
131    use super::*;
132
133    /// A folder of this test's own; the real settings folder is never touched.
134    fn folder(name: &str) -> PathBuf {
135        let path = std::env::temp_dir().join(format!("quvyta-settle-{name}-{}", std::process::id()));
136        let _ = fs::remove_dir_all(&path);
137        fs::create_dir_all(&path).expect("the folder");
138        path
139    }
140
141    fn read(folder: &Path, name: &str) -> String {
142        fs::read_to_string(folder.join(name)).unwrap_or_default()
143    }
144
145    const SHARED: &str = "language = \"tr\"\ntheme = \"nordic\"\nicons = \"unicode\"\n";
146
147    #[test]
148    fn a_value_equal_to_the_shared_one_follows_the_ecosystem() {
149        let folder = folder("equal");
150        fs::write(folder.join("quvyta.conf"), SHARED).expect("shared");
151        fs::write(
152            folder.join("desktop.conf"),
153            "language = \"tr\"\ntheme = \"nordic\"\nicons = \"unicode\"\ndock = 3\n",
154        )
155        .expect("own");
156        let following = Ecosystem::QUVYTA.settle_in(&folder, "desktop").expect("settle");
157        assert_eq!(following, [Shared::Language, Shared::Theme, Shared::Icons]);
158        assert_eq!(
159            read(&folder, "desktop.conf"),
160            "language = \"quvyta\"\ntheme = \"quvyta\"\nicons = \"quvyta\"\ndock = 3\nshared-checked = true\n"
161        );
162        assert_eq!(read(&folder, "quvyta.conf"), SHARED, "the shared file is only read");
163        let _ = fs::remove_dir_all(&folder);
164    }
165
166    #[test]
167    fn a_value_other_than_the_shared_one_stays() {
168        let folder = folder("different");
169        fs::write(folder.join("quvyta.conf"), SHARED).expect("shared");
170        fs::write(folder.join("code.conf"), "language = \"en\"\ntheme = \"nordic\"\nicons = \"nerd\"\n").expect("own");
171        let following = Ecosystem::QUVYTA.settle_in(&folder, "code").expect("settle");
172        assert_eq!(following, [Shared::Theme]);
173        let own = read(&folder, "code.conf");
174        assert!(own.contains("language = \"en\""), "the person's language stays: {own}");
175        assert!(own.contains("icons = \"nerd\""), "and so do the icons: {own}");
176        assert!(own.contains("theme = \"quvyta\""), "{own}");
177        let _ = fs::remove_dir_all(&folder);
178    }
179
180    #[test]
181    fn reduced_motion_kept_off_follows_and_reduced_stays_when_the_shared_file_never_held_it() {
182        let folder = folder("motion");
183        fs::write(folder.join("quvyta.conf"), SHARED).expect("shared");
184        fs::write(folder.join("code.conf"), "reduced-motion = false\n").expect("code");
185        fs::write(folder.join("focus.conf"), "reduced-motion = true\n").expect("focus");
186        assert_eq!(Ecosystem::QUVYTA.settle_in(&folder, "code").expect("code"), [Shared::ReducedMotion]);
187        assert_eq!(read(&folder, "code.conf"), "reduced-motion = \"quvyta\"\nshared-checked = true\n");
188        assert!(Ecosystem::QUVYTA.settle_in(&folder, "focus").expect("focus").is_empty());
189        assert!(read(&folder, "focus.conf").contains("reduced-motion = true"), "the person's need stays");
190        // Once the ecosystem reduces motion too, the same need follows it.
191        fs::write(folder.join("quvyta.conf"), format!("{SHARED}reduced-motion = true\n")).expect("shared");
192        fs::write(folder.join("focus.conf"), "reduced-motion = true\n").expect("focus");
193        assert_eq!(Ecosystem::QUVYTA.settle_in(&folder, "focus").expect("again"), [Shared::ReducedMotion]);
194        let _ = fs::remove_dir_all(&folder);
195    }
196
197    #[test]
198    fn the_second_run_changes_nothing() {
199        let folder = folder("twice");
200        fs::write(folder.join("quvyta.conf"), SHARED).expect("shared");
201        fs::write(folder.join("code.conf"), "theme = \"iris\"\n").expect("own");
202        assert!(Ecosystem::QUVYTA.settle_in(&folder, "code").expect("first").is_empty());
203        // The person now keeps the shared theme here alone, on purpose.
204        let mut own = Settings::open(folder.join("code.conf")).member_of(&Ecosystem::QUVYTA);
205        own.set("theme", "nordic".to_owned());
206        own.save().expect("the choice");
207        let before = read(&folder, "code.conf");
208        assert!(Ecosystem::QUVYTA.settle_in(&folder, "code").expect("second").is_empty());
209        assert_eq!(read(&folder, "code.conf"), before, "the choice is not undone");
210        assert!(before.contains("theme = \"nordic\""), "{before}");
211        let _ = fs::remove_dir_all(&folder);
212    }
213
214    #[test]
215    fn a_missing_file_takes_only_the_mark() {
216        let folder = folder("missing");
217        fs::write(folder.join("quvyta.conf"), SHARED).expect("shared");
218        assert!(Ecosystem::QUVYTA.settle_in(&folder, "code").expect("settle").is_empty());
219        assert_eq!(read(&folder, "code.conf"), "shared-checked = true\n");
220        let bare = folder.join("bare");
221        assert!(Ecosystem::QUVYTA.settle_in(&bare, "code").expect("no shared file either").is_empty());
222        assert_eq!(read(&bare, "code.conf"), "shared-checked = true\n");
223        let _ = fs::remove_dir_all(&folder);
224    }
225
226    #[test]
227    fn a_broken_file_is_left_as_it_was() {
228        let folder = folder("broken");
229        fs::write(folder.join("quvyta.conf"), SHARED).expect("shared");
230        fs::write(folder.join("code.conf"), "theme = \nicons = \"nerd\"\n").expect("own");
231        let error = Ecosystem::QUVYTA.settle_in(&folder, "code").expect_err("broken");
232        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
233        assert_eq!(read(&folder, "code.conf"), "theme = \nicons = \"nerd\"\n");
234        let _ = fs::remove_dir_all(&folder);
235    }
236
237    #[test]
238    fn the_mark_survives_a_member_s_self_healing() {
239        let text = "theme = \"nordic\"\nshared-checked = true\n";
240        let healed = Settings::parse_str("code.conf", text)
241            .member_of(&Ecosystem::QUVYTA)
242            .schema(super::super::Schema::builtin())
243            .self_heal(true);
244        assert_eq!(healed.get::<bool>(Settings::SHARED_CHECKED), Some(true));
245        assert!(healed.diagnostics().is_empty(), "{:?}", healed.diagnostics());
246    }
247}