Skip to main content

qframe/storage/
mod.rs

1//! Settings storage: an application's preferences in a TOML file in the platform config
2//! directory.
3//!
4//! ```toml
5//! theme = "nordic"
6//! language = "tr"
7//!
8//! [editor]
9//! tab-width = 4
10//! ```
11//!
12//! Keys are dotted paths (`"editor.tab-width"`); the dots become TOML tables. Loading never
13//! fails: a broken file yields located [`Diagnostic`]s, broken entries are skipped and the rest
14//! is used. Saving goes through [`atomic_write`], so a crash never leaves half a file; a file
15//! that could not be read completely is kept under its own name with `.bak` added
16//! (`settings.toml.bak`, `code.conf.bak`) before it is first overwritten.
17//!
18//! The module also holds what every application needs around its own files, settings or not:
19//! [`config_dir`] and [`data_dir`] for the two folders a platform gives an application and
20//! [`state_dir`] and [`cache_dir`] for what it remembers between runs and what it can rebuild,
21//! [`atomic_write`] for writing any file safely, [`AppLock`] for "one instance at a time" and
22//! [`InstanceLock`] for "wake me when the last instance closes",
23//! [`machine_name`] for keeping one file per machine in a folder several machines share, and
24//! [`FolderWatch`] for hearing about changes in a folder the moment the system sees them.
25//!
26//! Applications made to be used together keep their settings in one folder: an [`Ecosystem`] names
27//! it, gives each application its `<app>.conf` file and a folder beside it, and
28//! [`Ecosystem::adopt`] moves an application's settings there from the folder it used on its own.
29//! [`user_dir`] (the desktop, documents, downloads and the rest, by their names in the person's
30//! language), [`documents_dir`] and [`Ecosystem::workspace_dir`] say where the user's own work goes.
31//! [`Ecosystem::preferences`] resolves the language, theme and icons the ecosystem's applications share,
32//! and [`Ecosystem::set`] changes one of them for every application or for one.
33//!
34//! An application can describe its keys with a [`Schema`]. Loading then checks every key against
35//! it, and with [`Settings::self_heal`] on it repairs the file: unknown keys are removed, invalid
36//! values are replaced by their default, and each repair is reported. Optional keys
37//! ([`Schema::optional`]) are kept only while valid, and keys under an open prefix
38//! ([`Schema::open`]) are kept as they are. A missing key is never written: reading it gives
39//! `None` and the application falls back to its default.
40
41mod atomic;
42mod dirs;
43mod ecosystem;
44mod folder_watch;
45#[cfg(test)]
46mod healing_tests;
47mod instance_lock;
48mod lock;
49mod machine;
50mod member;
51mod migrate;
52mod preferences;
53mod schema;
54mod settle;
55mod update_notice;
56mod user_dirs;
57mod value;
58
59use std::fs;
60use std::io;
61use std::path::{Path, PathBuf};
62
63use toml::de::{DeTable, DeValue};
64
65pub use atomic::{WriteStep, atomic_write, atomic_write_reporting};
66pub use dirs::{cache_dir, config_dir, data_dir, state_dir};
67pub use ecosystem::Ecosystem;
68pub(crate) use user_dirs::user_dir_line;
69pub use user_dirs::{UserDir, documents_dir, user_dir, user_dir_in};
70/// The former name of [`Ecosystem`]. It still works so applications can move over at their own
71/// pace; a later release marks it deprecated, and the one after removes it. New code uses
72/// [`Ecosystem`].
73pub type Family = Ecosystem;
74pub use folder_watch::{FolderChange, FolderChangeKind, FolderChanges, FolderWatch};
75pub use instance_lock::InstanceLock;
76pub use lock::{AppLock, holder_pid};
77pub use machine::machine_name;
78pub use member::{MEMBERS, Member};
79pub use migrate::Migration;
80pub use preferences::{Preferences, Resolved, Scope, Shared, Source};
81pub use schema::{Schema, SettingKind};
82pub use value::{Setting, SettingValue};
83
84/// The name of the file application or ecosystem `id` keeps in the ecosystem's folder, such as
85/// `code.conf`; for the runtime, which watches those files.
86pub(crate) fn ecosystem_file_name(id: &str) -> String {
87    ecosystem::file_name(id)
88}
89
90use crate::diagnostics::{Diagnostic, Location};
91use crate::doc::Doc;
92use crate::icons::IconMode;
93use crate::runtime::Command;
94
95/// The file name settings are stored under.
96const FILE_NAME: &str = "settings.toml";
97
98/// An application's settings: typed values under dotted keys, loaded from and saved to one
99/// TOML file.
100#[derive(Debug, Clone, Default, PartialEq)]
101pub struct Settings {
102    path: Option<PathBuf>,
103    values: Vec<(String, SettingValue)>,
104    diagnostics: Vec<Diagnostic>,
105    keep_backup: bool,
106    /// Where each key was written in the loaded file, for diagnostics.
107    origins: Vec<(String, Location)>,
108    /// Keys of the loaded file whose value settings could not store.
109    skipped: Vec<String>,
110    /// How many diagnostics came from reading the file; the schema check comes after them.
111    read_problems: usize,
112    /// Repairs made so far; they stay reported when the check runs again.
113    repairs: Vec<Diagnostic>,
114    schema: Option<Schema>,
115    self_heal: bool,
116    /// The ecosystem these settings belong to, whose id stands for "follow the shared file" in the
117    /// keys every member shares.
118    ecosystem: Option<Ecosystem>,
119}
120
121impl Settings {
122    /// The key of the theme id.
123    pub const THEME: &'static str = "theme";
124    /// The key of the locale code.
125    pub const LANGUAGE: &'static str = "language";
126    /// The key of the icon mode: `auto`, `nerd`, `unicode` or `ascii`.
127    pub const ICONS: &'static str = "icons";
128    /// The key of the reduced motion flag.
129    pub const REDUCED_MOTION: &'static str = "reduced-motion";
130    /// The key of the pillar style: `thick` or `thin`.
131    pub const PILLAR: &'static str = "pillar";
132    /// The key of the selection slide flag.
133    pub const SLIDE: &'static str = "slide";
134    /// The key of the update notice, in an ecosystem's shared file: whether its applications ask once
135    /// a day if a newer version is out. On when the key is missing.
136    pub const UPDATE_NOTICE: &'static str = "update-notice";
137    /// The key [`Ecosystem::settle`] leaves in an application's own file once it has looked at
138    /// the shared keys there, so it never looks again. Every member's settings know it, whatever
139    /// their [schema](Self::schema) says, so self-healing keeps it.
140    pub const SHARED_CHECKED: &'static str = "shared-checked";
141
142    /// Settings that live only in memory; saving does nothing. For tests and for applications
143    /// run without a config directory.
144    #[must_use]
145    pub fn in_memory() -> Self {
146        Self::default()
147    }
148
149    /// Loads the settings of application `app` from the platform config directory:
150    /// `$XDG_CONFIG_HOME/<app>/settings.toml` or `~/.config/<app>/settings.toml` on Linux and
151    /// other Unix systems, `~/Library/Application Support/<app>/settings.toml` on macOS and
152    /// `%APPDATA%\<app>\settings.toml` on Windows. Without a home directory the settings stay in
153    /// memory and a diagnostic says why.
154    #[must_use]
155    pub fn load(app: &str) -> Self {
156        Self::open_or_keep_in_memory(config_dir(app).map(|dir| dir.join(FILE_NAME)))
157    }
158
159    /// Loads the settings of application `app` of `ecosystem` from its file in the ecosystem's folder,
160    /// [`Ecosystem::app_file`]: `~/.config/quvyta/code.conf` for `code` of [`Ecosystem::QUVYTA`] on
161    /// Linux. Without a home directory the settings stay in memory and a diagnostic says why, as
162    /// with [`load`](Self::load). Call [`Ecosystem::adopt`] first to bring the settings over from
163    /// the folder the application used before.
164    #[must_use]
165    pub fn load_member(ecosystem: &Ecosystem, app: &str) -> Self {
166        Self::open_or_keep_in_memory(ecosystem.app_file(app)).member_of(ecosystem)
167    }
168
169    /// Marks these settings as those of a member of `ecosystem`, for settings loaded with
170    /// [`open`](Self::open) from a folder of the application's choosing;
171    /// [`load_member`](Self::load_member) does it itself.
172    ///
173    /// The ecosystem's id (`"quvyta"` for [`Ecosystem::QUVYTA`]) is then a valid value of every key
174    /// [`Shared`] names, whatever the [schema](Self::schema) says: it means "use the ecosystem's
175    /// shared value", see [`Ecosystem::preferences`]. Self-healing keeps it, and
176    /// [`theme`](Self::theme), [`language`](Self::language) and [`icon_mode`](Self::icon_mode)
177    /// give `None` for it, so [`apply`](Self::apply) leaves those keys to the
178    /// [preferences](Preferences). Call it before [`self_heal`](Self::self_heal), which repairs
179    /// the file as soon as it is turned on.
180    ///
181    /// ```
182    /// use qframe::storage::{Ecosystem, Schema, Settings};
183    ///
184    /// let text = "theme = \"quvyta\"\nicons = \"quvyta\"\n";
185    /// let settings = Settings::parse_str("code.conf", text)
186    ///     .member_of(&Ecosystem::QUVYTA)
187    ///     .schema(Schema::builtin().choice(Settings::THEME, ["monochrome", "nordic"], "monochrome"))
188    ///     .self_heal(true);
189    /// assert!(settings.diagnostics().is_empty());
190    /// assert_eq!(settings.get::<String>(Settings::THEME).as_deref(), Some("quvyta"));
191    /// assert_eq!(settings.theme(), None, "follows the ecosystem");
192    /// ```
193    #[must_use]
194    pub fn member_of(mut self, ecosystem: &Ecosystem) -> Self {
195        self.ecosystem = Some(*ecosystem);
196        self.review();
197        self
198    }
199
200    /// Whether `value` under `key` means "follow the ecosystem's shared value".
201    fn follows_ecosystem(&self, key: &str, value: &SettingValue) -> bool {
202        let Some(ecosystem) = self.ecosystem else { return false };
203        Shared::ALL.iter().any(|shared| shared.key() == key)
204            && matches!(value, SettingValue::Text(text) if text == ecosystem.id())
205    }
206
207    /// The settings at `path`, or settings in memory with the reason when there is no path.
208    fn open_or_keep_in_memory(path: Option<PathBuf>) -> Self {
209        match path {
210            Some(path) => Self::open(path),
211            None => {
212                let mut settings = Self::in_memory();
213                settings.read_problem(Diagnostic::warning(None, "no config directory found; settings are not saved"));
214                settings
215            }
216        }
217    }
218
219    /// Puts `diagnostics` found around loading, such as what [`Ecosystem::adopt`] left behind, in
220    /// front of what reading the file found, so [`diagnostics`](Self::diagnostics) shows them
221    /// together. They stay when a [schema](Self::schema) check runs again.
222    #[must_use]
223    pub fn with_diagnostics(mut self, diagnostics: impl IntoIterator<Item = Diagnostic>) -> Self {
224        let before: Vec<Diagnostic> = diagnostics.into_iter().collect();
225        self.read_problems += before.len();
226        self.diagnostics.splice(0..0, before);
227        self
228    }
229
230    /// Loads settings from `path`. A missing file is an empty start, not a problem.
231    #[must_use]
232    pub fn open(path: impl Into<PathBuf>) -> Self {
233        let path = path.into();
234        let mut settings = Self { path: Some(path.clone()), ..Self::default() };
235        match fs::read_to_string(&path) {
236            Ok(text) => settings.parse(&display_name(&path), &text),
237            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
238            Err(error) => {
239                settings.keep_backup = true;
240                settings.read_problem(Diagnostic::error(None, format!("{}: {error}", path.display())));
241            }
242        }
243        settings
244    }
245
246    /// Records a problem found while reading, which a later schema check keeps.
247    fn read_problem(&mut self, diagnostic: Diagnostic) {
248        self.diagnostics.push(diagnostic);
249        self.read_problems = self.diagnostics.len();
250    }
251
252    /// Reads settings from TOML `text`, reporting problems against `file`. Saving does nothing.
253    #[must_use]
254    pub fn parse_str(file: &str, text: &str) -> Self {
255        let mut settings = Self::default();
256        settings.parse(file, text);
257        settings
258    }
259
260    fn parse(&mut self, file: &str, text: &str) {
261        let (root, errors) = Doc::new(file, text).parse_recoverable();
262        self.keep_backup |= !errors.is_empty();
263        self.diagnostics.extend(errors);
264        let mut reader = Reader { file, text, settings: self };
265        reader.table(&root, "");
266        self.read_problems = self.diagnostics.len();
267        self.review();
268    }
269
270    /// Checks the loaded keys against `schema` instead of only the built-in keys: keys it does
271    /// not know and values it does not accept become located warnings. The file is not touched
272    /// unless [`self_heal`](Self::self_heal) is on.
273    ///
274    /// ```
275    /// use qframe::storage::{Schema, Settings};
276    ///
277    /// let text = "language = \"tr\"\ncolor = \"red\"\npillar = \"thick\"\n";
278    /// let checked = Settings::parse_str("settings.toml", text).schema(Schema::builtin());
279    /// assert_eq!(checked.diagnostics()[0].to_string(), "settings.toml:2:1: warning: `color` is not a known setting; it is ignored");
280    /// assert!(checked.value("color").is_some(), "kept while self-healing is off");
281    /// ```
282    #[must_use]
283    pub fn schema(mut self, schema: Schema) -> Self {
284        self.schema = Some(schema);
285        self.review();
286        self
287    }
288
289    /// Repairs the loaded settings by the [`schema`](Self::schema). Every key is checked on its
290    /// own: valid keys are kept, unknown keys are removed and invalid values are replaced by their
291    /// default. An invalid [optional](Schema::optional) key is removed, since it has no default;
292    /// keys under an [open](Schema::open) prefix are kept as they are unless a rule declares them.
293    /// Missing keys are not added. Key order is never a problem and is left as it is. When
294    /// anything changed, the file as it was is kept under its name with `.bak` added
295    /// (`settings.toml.bak`) and the repaired settings are saved once; every repair is a located warning in
296    /// [`diagnostics`](Self::diagnostics).
297    ///
298    /// Off by default. Only the application knows all of its keys, so nothing is repaired until
299    /// a schema is given; the order of the two calls does not matter.
300    #[must_use]
301    pub fn self_heal(mut self, on: bool) -> Self {
302        self.self_heal = on;
303        self.review();
304        self
305    }
306
307    /// Checks every key against the schema (the built-in one until the application gives its
308    /// own) and, when healing, repairs what the check finds.
309    fn review(&mut self) {
310        self.diagnostics.truncate(self.read_problems);
311        self.diagnostics.extend(self.repairs.iter().cloned());
312        let explicit = self.schema.is_some();
313        let heal = self.self_heal && explicit;
314        let schema = self.schema.clone().unwrap_or_else(Schema::builtin);
315        let mut changed = false;
316        let keys: Vec<String> = self.keys().map(str::to_owned).collect();
317        for key in keys {
318            let location = self.origin(&key);
319            let Some(rule) = schema.get(&key) else {
320                if schema.is_open(&key) || (self.ecosystem.is_some() && key == Self::SHARED_CHECKED) {
321                    continue;
322                }
323                if heal {
324                    self.remove(&key);
325                    self.repaired(Diagnostic::warning(location, format!("`{key}` is not a known setting; removed")));
326                    changed = true;
327                } else if explicit {
328                    self.diagnostics
329                        .push(Diagnostic::warning(location, format!("`{key}` is not a known setting; it is ignored")));
330                }
331                continue;
332            };
333            let Some(value) =
334                self.value(&key).filter(|value| !rule.accepts(value) && !self.follows_ecosystem(&key, value))
335            else {
336                continue;
337            };
338            let found = value.literal();
339            let expected = rule.describe();
340            if heal {
341                let message = match rule.default_value().cloned() {
342                    Some(default) => {
343                        let message =
344                            format!("`{key}` must be {expected}, found {found}; replaced with {}", default.literal());
345                        self.store(&key, default);
346                        message
347                    }
348                    None => {
349                        self.remove(&key);
350                        format!("`{key}` must be {expected}, found {found}; removed")
351                    }
352                };
353                self.repaired(Diagnostic::warning(location, message));
354                changed = true;
355            } else {
356                self.diagnostics.push(Diagnostic::warning(
357                    location,
358                    format!("`{key}` must be {expected}, found {found}; it is ignored"),
359                ));
360            }
361        }
362        if heal {
363            // A known key whose entry settings could not store (a date, say) is invalid as well.
364            for key in std::mem::take(&mut self.skipped) {
365                if let Some(rule) = schema.get(&key)
366                    && self.value(&key).is_none()
367                {
368                    let message = match rule.default_value().cloned() {
369                        Some(default) => {
370                            let message = format!(
371                                "`{key}` holds a value settings cannot store; replaced with {}",
372                                default.literal()
373                            );
374                            self.store(&key, default);
375                            message
376                        }
377                        None => format!("`{key}` holds a value settings cannot store; removed"),
378                    };
379                    self.repaired(Diagnostic::warning(self.origin(&key), message));
380                    changed = true;
381                }
382            }
383            changed |= self.separate_tables(&schema);
384        }
385        if changed && self.path.is_some() {
386            // Healing drops what the user wrote, so the file as it was stays next to the repaired one.
387            self.keep_backup = true;
388            if let Err(error) = self.save() {
389                let place = self.path.as_deref().map(|path| path.display().to_string()).unwrap_or_default();
390                self.diagnostics
391                    .push(Diagnostic::error(None, format!("{place}: repaired settings not saved: {error}")));
392            }
393        }
394    }
395
396    /// Removes every key that sits where another kept key has a table (`plugins.git = true` next
397    /// to `plugins.git.sign = true`), which one TOML file cannot hold. A declared key wins over an
398    /// open one; otherwise the key written first stays. Returns whether anything was removed.
399    fn separate_tables(&mut self, schema: &Schema) -> bool {
400        let nested = |a: &str, b: &str| {
401            let (short, long) = if a.len() < b.len() { (a, b) } else { (b, a) };
402            long.strip_prefix(short).is_some_and(|rest| rest.starts_with('.'))
403        };
404        let mut removed = false;
405        loop {
406            let clash = self.values.iter().enumerate().find_map(|(later, (key, _))| {
407                self.values[..later].iter().position(|(kept, _)| nested(kept, key)).map(|first| (first, later))
408            });
409            let Some((first, later)) = clash else { break };
410            let declared = |index: usize| schema.get(&self.values[index].0).is_some();
411            let (gone, stays) = if declared(later) && !declared(first) { (first, later) } else { (later, first) };
412            let kept = self.values[stays].0.clone();
413            let (key, _) = self.values.remove(gone);
414            let message = format!("`{key}` cannot sit next to `{kept}` in one file; removed");
415            self.repaired(Diagnostic::warning(self.origin(&key), message));
416            removed = true;
417        }
418        removed
419    }
420
421    fn repaired(&mut self, diagnostic: Diagnostic) {
422        self.repairs.push(diagnostic.clone());
423        self.diagnostics.push(diagnostic);
424    }
425
426    /// Stores `value` under `key`, replacing an existing value where it is so the key keeps its
427    /// place. Returns whether anything changed.
428    pub(crate) fn store(&mut self, key: &str, value: SettingValue) -> bool {
429        match self.values.iter_mut().find(|(k, _)| k == key) {
430            Some((_, current)) if *current == value => false,
431            Some((_, current)) => {
432                *current = value;
433                true
434            }
435            None => {
436                self.values.push((key.to_owned(), value));
437                true
438            }
439        }
440    }
441
442    /// Where `key` was written in the loaded file.
443    fn origin(&self, key: &str) -> Option<Location> {
444        self.origins.iter().find(|(k, _)| k == key).map(|(_, location)| location.clone())
445    }
446
447    /// Where the settings are saved, if anywhere.
448    #[must_use]
449    pub fn path(&self) -> Option<&Path> {
450        self.path.as_deref()
451    }
452
453    /// Problems found while loading.
454    #[must_use]
455    pub fn diagnostics(&self) -> &[Diagnostic] {
456        &self.diagnostics
457    }
458
459    /// The raw value under `key`.
460    #[must_use]
461    pub fn value(&self, key: &str) -> Option<&SettingValue> {
462        self.values.iter().find(|(k, _)| k == key).map(|(_, value)| value)
463    }
464
465    /// The value under `key` as `T`; `None` when missing or of another type.
466    #[must_use]
467    pub fn get<T: Setting>(&self, key: &str) -> Option<T> {
468        self.value(key).and_then(T::from_setting)
469    }
470
471    /// The value under `key` as `T`, or `default`.
472    #[must_use]
473    pub fn get_or<T: Setting>(&self, key: &str, default: T) -> T {
474        self.get(key).unwrap_or(default)
475    }
476
477    /// Stores `value` under `key`. Returns whether anything changed.
478    pub fn set<T: Setting>(&mut self, key: &str, value: T) -> bool {
479        self.store(key, value.to_setting())
480    }
481
482    /// Removes `key`. Returns whether it existed.
483    pub fn remove(&mut self, key: &str) -> bool {
484        let before = self.values.len();
485        self.values.retain(|(k, _)| k != key);
486        before != self.values.len()
487    }
488
489    /// Every key, in file order.
490    pub fn keys(&self) -> impl Iterator<Item = &str> {
491        self.values.iter().map(|(key, _)| key.as_str())
492    }
493
494    /// The saved theme id; `None` when the file says to follow the ecosystem, see
495    /// [`member_of`](Self::member_of).
496    #[must_use]
497    pub fn theme(&self) -> Option<String> {
498        self.own(Self::THEME)
499    }
500
501    /// The saved locale code; `None` when the file says to follow the ecosystem, see
502    /// [`member_of`](Self::member_of).
503    #[must_use]
504    pub fn language(&self) -> Option<String> {
505        self.own(Self::LANGUAGE)
506    }
507
508    /// The text under `key` unless it says to follow the ecosystem.
509    fn own(&self, key: &str) -> Option<String> {
510        self.value(key).filter(|value| !self.follows_ecosystem(key, value)).and_then(String::from_setting)
511    }
512
513    /// The saved icon mode.
514    #[must_use]
515    pub fn icon_mode(&self) -> Option<IconMode> {
516        self.get::<String>(Self::ICONS).and_then(|name| IconMode::from_name(&name))
517    }
518
519    /// The saved reduced motion flag.
520    #[must_use]
521    pub fn reduced_motion(&self) -> Option<bool> {
522        self.get(Self::REDUCED_MOTION)
523    }
524
525    /// The saved pillar style.
526    #[must_use]
527    pub fn pillar_style(&self) -> Option<crate::icons::PillarStyle> {
528        self.get::<String>(Self::PILLAR).and_then(|name| crate::icons::PillarStyle::from_name(&name))
529    }
530
531    /// The saved selection slide flag.
532    #[must_use]
533    pub fn slide(&self) -> Option<bool> {
534        self.get(Self::SLIDE)
535    }
536
537    /// Commands that switch theme, language, icons, reduced motion, pillar and slide to the saved values;
538    /// nothing for values that are not saved.
539    #[must_use]
540    pub fn apply<Msg: Send + 'static>(&self) -> Command<Msg> {
541        let mut commands = Vec::new();
542        if let Some(theme) = self.theme() {
543            commands.push(Command::set_theme(theme));
544        }
545        if let Some(language) = self.language() {
546            commands.push(Command::set_locale(language));
547        }
548        if let Some(mode) = self.icon_mode() {
549            commands.push(Command::set_icon_mode(mode));
550        }
551        if let Some(reduced) = self.reduced_motion() {
552            commands.push(Command::set_reduced_motion(reduced));
553        }
554        if let Some(style) = self.pillar_style() {
555            commands.push(Command::set_pillar(style));
556        }
557        if let Some(slide) = self.slide() {
558            commands.push(Command::set_slide(slide));
559        }
560        Command::batch(commands)
561    }
562
563    /// The settings as TOML text: plain keys first, then one table per dotted prefix.
564    #[must_use]
565    pub fn to_toml(&self) -> String {
566        let mut out = String::new();
567        let mut sections: Vec<(&str, Vec<(&str, &SettingValue)>)> = Vec::new();
568        for (key, value) in &self.values {
569            let (section, name) = key.rsplit_once('.').unwrap_or(("", key));
570            match sections.iter_mut().find(|(s, _)| *s == section) {
571                Some((_, entries)) => entries.push((name, value)),
572                None => sections.push((section, vec![(name, value)])),
573            }
574        }
575        sections.sort_by_key(|(section, _)| !section.is_empty());
576        for (section, entries) in sections {
577            if !section.is_empty() {
578                if !out.is_empty() {
579                    out.push('\n');
580                }
581                out.push('[');
582                for (index, part) in section.split('.').enumerate() {
583                    if index > 0 {
584                        out.push('.');
585                    }
586                    value::key(part, &mut out);
587                }
588                out.push_str("]\n");
589            }
590            for (name, value) in entries {
591                value::key(name, &mut out);
592                out.push_str(" = ");
593                value.write(&mut out);
594                out.push('\n');
595            }
596        }
597        out
598    }
599
600    /// Writes the settings to their file atomically, creating the directory when needed.
601    /// In-memory settings do nothing. A file that was loaded with problems, or that healing
602    /// changed, is first copied next to itself under its name with `.bak` added.
603    ///
604    /// # Errors
605    ///
606    /// Returns the I/O error when the directory or file cannot be written.
607    pub fn save(&mut self) -> io::Result<()> {
608        let Some(path) = self.path.clone() else {
609            return Ok(());
610        };
611        if let Some(dir) = path.parent() {
612            fs::create_dir_all(dir)?;
613        }
614        if self.keep_backup && path.exists() {
615            fs::copy(&path, backup_path(&path))?;
616        }
617        atomic_write(&path, self.to_toml().as_bytes())?;
618        self.keep_backup = false;
619        Ok(())
620    }
621
622    /// Saves a copy of the settings on a background thread and reports the result, so a slow
623    /// disk never holds up drawing. Use it right after changing a value in `update`.
624    #[must_use]
625    pub fn save_command<Msg: Send + 'static>(
626        &self,
627        done: impl FnOnce(Result<(), String>) -> Msg + Send + 'static,
628    ) -> Command<Msg> {
629        let mut copy = self.clone();
630        Command::perform(move || done(copy.save().map_err(|error| error.to_string())))
631    }
632}
633
634/// Walks a parsed document into dotted keys.
635struct Reader<'a> {
636    file: &'a str,
637    text: &'a str,
638    settings: &'a mut Settings,
639}
640
641impl Reader<'_> {
642    fn table(&mut self, table: &DeTable<'_>, prefix: &str) {
643        for (name, value) in table {
644            let key =
645                if prefix.is_empty() { name.get_ref().to_string() } else { format!("{prefix}.{}", name.get_ref()) };
646            if let DeValue::Table(inner) = value.get_ref() {
647                self.table(inner, &key);
648                continue;
649            }
650            // The key's own position: searching the text for the name would find it inside
651            // comments, values or longer keys.
652            let origin = Location::from_offset(self.file, self.text, name.span().start);
653            self.settings.origins.retain(|(k, _)| *k != key);
654            self.settings.origins.push((key.clone(), origin));
655            match self.value(value.get_ref()) {
656                Some(parsed) => {
657                    self.settings.values.retain(|(k, _)| *k != key);
658                    self.settings.values.push((key, parsed));
659                }
660                None => {
661                    self.settings.skipped.push(key.clone());
662                    let location = Location::from_offset(self.file, self.text, value.span().start);
663                    self.settings.diagnostics.push(Diagnostic::warning(
664                        Some(location),
665                        format!(
666                            "`{key}` holds a {} that settings do not store; it is skipped",
667                            value.get_ref().type_str()
668                        ),
669                    ));
670                    self.settings.keep_backup = true;
671                }
672            }
673        }
674    }
675
676    fn value(&self, value: &DeValue<'_>) -> Option<SettingValue> {
677        Some(match value {
678            DeValue::Boolean(flag) => SettingValue::Bool(*flag),
679            DeValue::String(text) => SettingValue::Text(text.to_string()),
680            DeValue::Integer(number) => {
681                SettingValue::Integer(i64::from_str_radix(number.as_str(), number.radix()).ok()?)
682            }
683            DeValue::Float(number) => SettingValue::Float(number.as_str().replace('_', "").parse().ok()?),
684            DeValue::Array(items) => {
685                SettingValue::List(items.iter().map(|item| self.value(item.get_ref())).collect::<Option<Vec<_>>>()?)
686            }
687            DeValue::Datetime(_) | DeValue::Table(_) => return None,
688        })
689    }
690}
691
692/// Where the file at `path` is kept before it is first overwritten: its whole name with `.bak`
693/// added, so `settings.toml` is kept as `settings.toml.bak` and `code.conf` as `code.conf.bak`.
694fn backup_path(path: &Path) -> PathBuf {
695    let mut name = path.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
696    name.push(".bak");
697    path.with_file_name(name)
698}
699
700fn display_name(path: &Path) -> String {
701    path.file_name().and_then(|name| name.to_str()).unwrap_or(FILE_NAME).to_owned()
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    fn temp_dir(name: &str) -> PathBuf {
709        let dir = std::env::temp_dir().join(format!("quvyta-storage-{name}-{}", std::process::id()));
710        let _ = fs::remove_dir_all(&dir);
711        dir
712    }
713
714    #[test]
715    fn typed_get_set_and_round_trip() {
716        let mut settings = Settings::in_memory();
717        assert!(settings.set(Settings::THEME, "nordic".to_owned()));
718        assert!(!settings.set(Settings::THEME, "nordic".to_owned()));
719        settings.set("editor.tab-width", 4u16);
720        settings.set("editor.ratio", 0.25f64);
721        settings.set("recent.projects", vec!["api".to_owned(), "web \"beta\"".to_owned()]);
722        settings.set(Settings::REDUCED_MOTION, true);
723        let text = settings.to_toml();
724        assert_eq!(
725            text,
726            "theme = \"nordic\"\nreduced-motion = true\n\n[editor]\ntab-width = 4\nratio = 0.25\n\n[recent]\nprojects = [\"api\", \"web \\\"beta\\\"\"]\n"
727        );
728        let back = Settings::parse_str("settings.toml", &text);
729        assert!(back.diagnostics().is_empty(), "{:?}", back.diagnostics());
730        assert_eq!(back.get::<u16>("editor.tab-width"), Some(4));
731        assert_eq!(back.get::<Vec<String>>("recent.projects").map(|p| p.len()), Some(2));
732        assert_eq!(back.get::<bool>("editor.tab-width"), None);
733        assert_eq!(back.get_or("missing", 7u8), 7);
734        assert_eq!(back.theme().as_deref(), Some("nordic"));
735    }
736
737    #[test]
738    fn broken_files_give_located_diagnostics_and_keep_good_values() {
739        let text =
740            "theme = \"amber\"\nlanguage = \nicons = \"sparkly\"\nreduced-motion = \"yes\"\nstarted = 2026-09-16\n";
741        let settings = Settings::parse_str("settings.toml", text);
742        assert_eq!(settings.theme().as_deref(), Some("amber"));
743        let lines: Vec<(usize, String)> = settings
744            .diagnostics()
745            .iter()
746            .map(|d| (d.location.as_ref().map_or(0, |l| l.line), d.message.clone()))
747            .collect();
748        assert!(lines.iter().any(|(line, _)| *line == 2), "{lines:?}");
749        assert!(lines.iter().any(|(line, m)| *line == 3 && m.contains("auto, nerd")), "{lines:?}");
750        assert!(lines.iter().any(|(line, m)| *line == 4 && m.contains("boolean")), "{lines:?}");
751        assert!(lines.iter().any(|(line, m)| *line == 5 && m.contains("datetime")), "{lines:?}");
752        assert_eq!(settings.icon_mode(), None);
753        assert_eq!(settings.reduced_motion(), None);
754    }
755
756    #[test]
757    fn saves_atomically_and_backs_up_broken_files() {
758        let dir = temp_dir("save");
759        let path = dir.join("nested").join(FILE_NAME);
760        let mut settings = Settings::open(&path);
761        assert!(settings.diagnostics().is_empty());
762        settings.set(Settings::LANGUAGE, "tr".to_owned());
763        settings.save().expect("saved");
764        assert_eq!(fs::read_to_string(&path).expect("written"), "language = \"tr\"\n");
765        let leftovers: Vec<_> = fs::read_dir(path.parent().expect("dir")).expect("list").collect();
766        assert_eq!(leftovers.len(), 1, "no temporary file stays behind");
767
768        fs::write(&path, "language = \"tr\"\nicons = [\n").expect("break the file");
769        let mut broken = Settings::open(&path);
770        assert!(!broken.diagnostics().is_empty());
771        assert_eq!(broken.language().as_deref(), Some("tr"));
772        broken.set(Settings::ICONS, "ascii".to_owned());
773        broken.save().expect("saved");
774        assert!(fs::read_to_string(path.with_extension("toml.bak")).expect("backup").contains("icons = ["));
775        assert_eq!(Settings::open(&path).icon_mode(), Some(IconMode::Ascii));
776        fs::remove_dir_all(&dir).expect("clean");
777    }
778
779    #[test]
780    fn the_backup_is_the_file_name_with_bak_added() {
781        let dir = temp_dir("backup-name");
782        fs::create_dir_all(&dir).expect("dir");
783        let path = dir.join("code.conf");
784        fs::write(&path, "language = \"tr\"\nicons = [\n").expect("a broken file");
785        let mut broken = Settings::open(&path);
786        broken.set(Settings::ICONS, "ascii".to_owned());
787        broken.save().expect("saved");
788        assert!(fs::read_to_string(dir.join("code.conf.bak")).expect("backup").contains("icons = ["));
789        assert!(!dir.join("code.toml.bak").exists(), "not named after another extension");
790        fs::remove_dir_all(&dir).expect("clean");
791    }
792
793    #[test]
794    fn a_ecosystem_member_loads_its_own_conf_file() {
795        let settings = Settings::load_member(&Ecosystem::QUVYTA, "code");
796        match Ecosystem::QUVYTA.app_file("code") {
797            Some(file) => assert_eq!(settings.path(), Some(file.as_path())),
798            None => assert_eq!(settings.diagnostics()[0].message, "no config directory found; settings are not saved"),
799        }
800    }
801
802    #[test]
803    fn diagnostics_from_around_loading_come_first_and_stay() {
804        let adopted = Diagnostic::warning(None, "old/settings.toml: new.conf already exists");
805        let settings = Settings::parse_str("code.conf", "color = \"red\"\n")
806            .with_diagnostics([adopted.clone()])
807            .schema(Schema::builtin())
808            .self_heal(false);
809        assert_eq!(settings.diagnostics().len(), 2, "{:?}", settings.diagnostics());
810        assert_eq!(settings.diagnostics()[0], adopted);
811        let checked = settings.schema(Schema::default());
812        assert_eq!(checked.diagnostics()[0], adopted, "a second check keeps it");
813    }
814
815    #[test]
816    fn apply_turns_saved_values_into_commands() {
817        let settings = Settings::parse_str("s.toml", "theme = \"iris\"\nicons = \"ascii\"\n");
818        let command: Command<()> = settings.apply();
819        assert_eq!(command.actions.len(), 2);
820    }
821}