Skip to main content

qframe/storage/
schema.rs

1//! The shape of an application's settings: which keys exist, what each may hold and what it
2//! falls back to. Loading checks a file against it and, with self-healing on, repairs the file.
3
4use std::fmt;
5use std::sync::Arc;
6
7use super::value::{Setting, SettingValue};
8use crate::icons::{IconMode, PillarStyle};
9
10/// Decides whether a stored value is acceptable for one key.
11type Valid = Arc<dyn Fn(&SettingValue) -> bool + Send + Sync>;
12
13/// What one key may hold.
14#[derive(Clone)]
15enum Allowed {
16    /// `true` or `false`.
17    Flag,
18    /// Any text.
19    Text,
20    /// Text from a fixed list.
21    Choice(Vec<String>),
22    /// Whatever the application's check accepts.
23    Check(Valid),
24}
25
26impl Allowed {
27    fn accepts(&self, value: &SettingValue) -> bool {
28        match (self, value) {
29            (Self::Flag, SettingValue::Bool(_)) | (Self::Text, SettingValue::Text(_)) => true,
30            (Self::Choice(choices), SettingValue::Text(text)) => choices.contains(text),
31            (Self::Check(valid), value) => valid(value),
32            _ => false,
33        }
34    }
35
36    /// The expectation in words, for diagnostics.
37    fn describe(&self) -> String {
38        match self {
39            Self::Flag => "a boolean".to_owned(),
40            Self::Text => "a string".to_owned(),
41            Self::Choice(choices) => format!("one of {}", choices.join(", ")),
42            Self::Check(_) => "a value this application accepts".to_owned(),
43        }
44    }
45}
46
47impl PartialEq for Allowed {
48    fn eq(&self, other: &Self) -> bool {
49        match (self, other) {
50            (Self::Flag, Self::Flag) | (Self::Text, Self::Text) => true,
51            (Self::Choice(a), Self::Choice(b)) => a == b,
52            // Two checks are the same check only when they are the same closure.
53            (Self::Check(a), Self::Check(b)) => Arc::ptr_eq(a, b),
54            _ => false,
55        }
56    }
57}
58
59/// What an optional key may hold, for [`Schema::optional`]: the same kinds as the builders with
60/// a default ([`Schema::flag`], [`Schema::text`], [`Schema::choice`], [`Schema::check`]), without
61/// the default.
62///
63/// A plain value rather than one builder per kind (`optional_flag`, `optional_text`, …): the kinds
64/// stay listed once, and any kind can be optional.
65///
66/// ```
67/// use qframe::storage::{Schema, SettingKind};
68///
69/// let schema = Schema::builtin()
70///     .optional("deploy.note", SettingKind::text())
71///     .optional("deploy.retries", SettingKind::check(|retries: &u8| (1..=10).contains(retries)));
72/// ```
73#[derive(Clone, PartialEq)]
74pub struct SettingKind(Allowed);
75
76impl SettingKind {
77    /// `true` or `false`.
78    #[must_use]
79    pub fn flag() -> Self {
80        Self(Allowed::Flag)
81    }
82
83    /// Any text.
84    #[must_use]
85    pub fn text() -> Self {
86        Self(Allowed::Text)
87    }
88
89    /// One text of `choices`.
90    #[must_use]
91    pub fn choice(choices: impl IntoIterator<Item = impl Into<String>>) -> Self {
92        Self(Allowed::Choice(choices.into_iter().map(Into::into).collect()))
93    }
94
95    /// A value that reads as `T` and passes `valid`.
96    #[must_use]
97    pub fn check<T: Setting + 'static>(valid: impl Fn(&T) -> bool + Send + Sync + 'static) -> Self {
98        Self(Allowed::Check(Arc::new(move |value| T::from_setting(value).is_some_and(|value| valid(&value)))))
99    }
100}
101
102impl fmt::Debug for SettingKind {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.write_str(&self.0.describe())
105    }
106}
107
108/// One key of a [`Schema`].
109#[derive(Clone, PartialEq)]
110pub(crate) struct Rule {
111    key: String,
112    allowed: Allowed,
113    /// `None` for an optional key: an invalid value is removed instead of replaced.
114    default: Option<SettingValue>,
115}
116
117impl Rule {
118    /// Whether `value` is valid for this key.
119    pub(crate) fn accepts(&self, value: &SettingValue) -> bool {
120        self.allowed.accepts(value)
121    }
122
123    /// What the key must hold, in words.
124    pub(crate) fn describe(&self) -> String {
125        self.allowed.describe()
126    }
127
128    /// The value written in place of an invalid one; `None` for an optional key.
129    pub(crate) fn default_value(&self) -> Option<&SettingValue> {
130        self.default.as_ref()
131    }
132}
133
134/// The settings an application knows: every key with what it may hold and its default.
135///
136/// Give it to [`Settings::schema`](super::Settings::schema) to check a loaded file, and turn on
137/// [`Settings::self_heal`](super::Settings::self_heal) to repair it: unknown keys are removed and
138/// invalid values are replaced by their default. Values known only while running, such as the
139/// installed themes and languages, are passed in when the schema is built.
140///
141/// Two capabilities are opt-in, each on its own:
142///
143/// - [`Schema::optional`] declares a key without a default. A valid value is kept, an invalid one
144///   is removed, and a missing one stays missing.
145/// - [`Schema::open`] keeps every key under a table as it is, for keys the application does not
146///   own, such as plugins' settings.
147///
148/// A key missing from the file is never written into it: reading it gives `None` and the
149/// application uses its default.
150///
151/// ```
152/// use qframe::storage::{Schema, Settings};
153///
154/// let schema = Schema::builtin()
155///     .choice(Settings::LANGUAGE, ["en", "tr"], "en")
156///     .choice("deploy.region", ["eu-west", "us-east"], "eu-west")
157///     .check("editor.tab-width", 4u16, |width| (1..=16).contains(width));
158/// let settings = Settings::parse_str("settings.toml", "language = \"sjds\"\ncolor = \"red\"\n")
159///     .schema(schema)
160///     .self_heal(true);
161/// assert_eq!(settings.language().as_deref(), Some("en"));
162/// assert!(settings.value("color").is_none());
163/// ```
164#[derive(Clone, Default, PartialEq)]
165pub struct Schema {
166    rules: Vec<Rule>,
167    /// Tables whose keys are kept unchecked, without a trailing dot.
168    open: Vec<String>,
169}
170
171impl Schema {
172    /// The keys the framework itself reads: `theme` and `language` (any string; `monochrome`
173    /// and `en`), `icons` (`auto`, `nerd`, `unicode`, `ascii`; `auto`), `reduced-motion`
174    /// (`false`), `pillar` (`thick`, `thin`; `thick`) and `slide` (`true`). Declare `theme` and
175    /// `language` again with [`Schema::choice`] to accept only what is installed.
176    #[must_use]
177    pub fn builtin() -> Self {
178        use super::Settings;
179        Self::default()
180            .text(Settings::THEME, "monochrome")
181            .text(Settings::LANGUAGE, "en")
182            .choice(Settings::ICONS, IconMode::ALL.map(IconMode::name), IconMode::Auto.name())
183            .flag(Settings::REDUCED_MOTION, false)
184            .choice(Settings::PILLAR, PillarStyle::ALL.map(PillarStyle::name), PillarStyle::Thick.name())
185            .flag(Settings::SLIDE, true)
186    }
187
188    /// A `true`/`false` key. Declaring a key again replaces its earlier rule.
189    #[must_use]
190    pub fn flag(self, key: &str, default: bool) -> Self {
191        self.rule(key, Allowed::Flag, Some(SettingValue::Bool(default)))
192    }
193
194    /// A key holding any text.
195    #[must_use]
196    pub fn text(self, key: &str, default: impl Into<String>) -> Self {
197        self.rule(key, Allowed::Text, Some(SettingValue::Text(default.into())))
198    }
199
200    /// A key holding one text of `choices`, e.g. the installed theme ids.
201    #[must_use]
202    pub fn choice(self, key: &str, choices: impl IntoIterator<Item = impl Into<String>>, default: &str) -> Self {
203        let SettingKind(allowed) = SettingKind::choice(choices);
204        self.rule(key, allowed, Some(SettingValue::Text(default.to_owned())))
205    }
206
207    /// A key whose value must read as `T` and pass `valid`, e.g. a number in a range or a name
208    /// without spaces.
209    #[must_use]
210    pub fn check<T: Setting + 'static>(
211        self,
212        key: &str,
213        default: T,
214        valid: impl Fn(&T) -> bool + Send + Sync + 'static,
215    ) -> Self {
216        let SettingKind(allowed) = SettingKind::check(valid);
217        self.rule(key, allowed, Some(default.to_setting()))
218    }
219
220    /// A key without a default, holding `kind`, e.g. a note an application stores only once the
221    /// user writes one. With self-healing on, a valid value is kept, an invalid one is removed
222    /// (there is nothing to replace it with) and a missing one is not added; reading a missing
223    /// key gives `None`.
224    ///
225    /// ```
226    /// use qframe::storage::{Schema, SettingKind, Settings};
227    ///
228    /// let schema = Schema::default().optional("deploy.note", SettingKind::text());
229    /// let healed = Settings::parse_str("settings.toml", "[deploy]\nnote = 42\n").schema(schema).self_heal(true);
230    /// assert_eq!(healed.get::<String>("deploy.note"), None);
231    /// assert_eq!(healed.to_toml(), "");
232    /// ```
233    #[must_use]
234    pub fn optional(self, key: &str, kind: SettingKind) -> Self {
235        let SettingKind(allowed) = kind;
236        self.rule(key, allowed, None)
237    }
238
239    /// Keeps every key under the dotted table `prefix` as it is: `open("plugins")` keeps
240    /// `[plugins]` and every table below it, unchecked and never removed, for settings the
241    /// application does not own. Keys declared under the prefix are still checked by their rule.
242    /// The prefix names a table, so a plain `plugins = …` key is not under it; `""` opens nothing.
243    ///
244    /// ```
245    /// use qframe::storage::{Schema, Settings};
246    ///
247    /// let schema = Schema::default().open("plugins").flag("plugins.enabled", true);
248    /// let text = "[plugins]\nenabled = \"yes\"\n\n[plugins.git]\nsign = true\n";
249    /// let healed = Settings::parse_str("settings.toml", text).schema(schema).self_heal(true);
250    /// assert_eq!(healed.to_toml(), "[plugins]\nenabled = true\n\n[plugins.git]\nsign = true\n");
251    /// ```
252    #[must_use]
253    pub fn open(mut self, prefix: &str) -> Self {
254        let prefix = prefix.trim_end_matches('.');
255        if !prefix.is_empty() && !self.open.iter().any(|open| open == prefix) {
256            self.open.push(prefix.to_owned());
257        }
258        self
259    }
260
261    fn rule(mut self, key: &str, allowed: Allowed, default: Option<SettingValue>) -> Self {
262        self.rules.retain(|rule| rule.key != key);
263        self.rules.push(Rule { key: key.to_owned(), allowed, default });
264        self
265    }
266
267    /// The rule of `key`, if the schema knows it.
268    pub(crate) fn get(&self, key: &str) -> Option<&Rule> {
269        self.rules.iter().find(|rule| rule.key == key)
270    }
271
272    /// Whether `key` lies under an [open](Self::open) prefix.
273    pub(crate) fn is_open(&self, key: &str) -> bool {
274        self.open.iter().any(|prefix| key.strip_prefix(prefix.as_str()).is_some_and(|rest| rest.starts_with('.')))
275    }
276}
277
278impl fmt::Debug for Schema {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        f.debug_struct("Schema")
281            .field("rules", &self.rules.iter().map(|rule| (&rule.key, rule.describe())).collect::<Vec<_>>())
282            .field("open", &self.open)
283            .finish()
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn rules_accept_only_their_values() {
293        let schema = Schema::builtin().check("editor.tab-width", 4u16, |width| (1..=16).contains(width));
294        let icons = schema.get("icons").expect("built in");
295        assert!(icons.accepts(&SettingValue::Text("ascii".into())));
296        assert!(!icons.accepts(&SettingValue::Text("sparkly".into())));
297        assert!(!icons.accepts(&SettingValue::Bool(true)));
298        assert_eq!(icons.describe(), "one of auto, nerd, unicode, ascii");
299        let slide = schema.get("slide").expect("built in");
300        assert!(slide.accepts(&SettingValue::Bool(false)));
301        assert!(!slide.accepts(&SettingValue::Text("true".into())));
302        let width = schema.get("editor.tab-width").expect("declared");
303        assert!(width.accepts(&SettingValue::Integer(8)));
304        assert!(!width.accepts(&SettingValue::Integer(40)));
305        assert!(!width.accepts(&SettingValue::Text("8".into())));
306        assert_eq!(width.default_value(), Some(&SettingValue::Integer(4)));
307        assert!(schema.get("color").is_none());
308    }
309
310    #[test]
311    fn declaring_a_key_again_replaces_it() {
312        let schema = Schema::builtin().choice("language", ["en", "tr"], "tr");
313        assert_eq!(schema.clone().choice("language", ["en", "tr"], "tr"), schema, "one rule per key");
314        let language = schema.get("language").expect("declared");
315        assert!(!language.accepts(&SettingValue::Text("sjds".into())));
316        assert_eq!(language.default_value(), Some(&SettingValue::Text("tr".into())));
317        assert_eq!(Schema::builtin(), Schema::builtin());
318        assert_ne!(schema, Schema::builtin());
319    }
320
321    #[test]
322    fn optional_rules_have_no_default_and_open_prefixes_name_tables() {
323        let schema = Schema::default()
324            .optional("deploy.note", SettingKind::text())
325            .optional("deploy.retries", SettingKind::check(|retries: &u8| (1..=10).contains(retries)))
326            .open("plugins")
327            .open("plugins.")
328            .open("");
329        let note = schema.get("deploy.note").expect("declared");
330        assert_eq!(note.default_value(), None);
331        assert!(note.accepts(&SettingValue::Text("freeze".into())) && !note.accepts(&SettingValue::Integer(1)));
332        let retries = schema.get("deploy.retries").expect("declared");
333        assert!(retries.accepts(&SettingValue::Integer(3)) && !retries.accepts(&SettingValue::Integer(30)));
334        assert!(schema.is_open("plugins.git") && schema.is_open("plugins.git.sign") && schema.is_open("plugins."));
335        assert!(!schema.is_open("plugins") && !schema.is_open("plugins-extra.x") && !schema.is_open("deploy.note"));
336        assert_eq!(format!("{schema:?}"), format!("{:?}", schema.clone().open("plugins")), "each prefix once");
337        let declared = Schema::default().text("deploy.note", "").optional("deploy.note", SettingKind::text());
338        assert_eq!(declared.get("deploy.note").and_then(Rule::default_value), None, "declaring again replaces");
339        assert_eq!(format!("{:?}", SettingKind::choice(["a", "b"])), "one of a, b");
340    }
341}