Skip to main content

max / quasi

19.1 KB · 532 lines History Blame Raw
1 //! The configuration a declaration generates.
2 //!
3 //! A [`Kind`] is a settings surface that has not been written down yet: an
4 //! on/off, one key per knob, a default for each, and a posture saying whether
5 //! each travels between a user's devices.
6 //!
7 //! # The generated key is dotted, and that is what keeps it apart
8 //!
9 //! `<kind>.enabled`, and `<kind>.<knob>` for each option. `is_key` refuses a
10 //! dot in either id for exactly this reason: the dot is the separator, so it
11 //! cannot also be inside a name.
12 //!
13 //! It also settles the collision this task was filed in front of. goingson
14 //! already has `event_lead_minutes`, and it is **not** a delivery setting --
15 //! its hint reads "How far in advance the Events tab dot turns yellow", so it
16 //! is about a coloured dot in a tab. The event-reminder kind wants a lead time
17 //! too, and named the same way the two would sit next to each other in one
18 //! table meaning different things. Generated keys are dotted and hand-written
19 //! app keys are not, so `event-reminder.lead` cannot be mistaken for it in the
20 //! store, in the policy table, or by a reader. The pane still owes the
21 //! distinction in words, which is the kind's [`summary`](Kind::summary).
22 //!
23 //! # Values are text, because the store is
24 //!
25 //! [`ConfigStore`](https://makenot.work/git/max/synckit) holds `String`s. So a
26 //! [`Value`] renders to text and parses back from it, and every default in this
27 //! crate has exactly one written form. An unparseable stored value reads as the
28 //! default rather than as an error: a settings pane that refuses to draw
29 //! because one row is corrupt is worse than one that shows the default and lets
30 //! the reader set it again.
31
32 use crate::{Kind, Knob, Posture, Registry, Setting};
33
34 /// Whether a generated key may be replicated to the user's other devices.
35 ///
36 /// [`synckit_config::Posture`] under another name, and the name is forced:
37 /// [`Posture`] here is already whether a kind ships on. The mapping is one to
38 /// one and `Reach::posture` (under the `synckit` feature) is it, so nothing chooses between two vocabularies
39 /// -- this crate cannot depend on `synckit-config` unconditionally, because that
40 /// crate carries a bundled SQLite and an app that only declares kinds should not
41 /// link one.
42 ///
43 /// [`synckit_config::Posture`]: https://makenot.work/git/max/synckit
44 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
45 pub enum Reach {
46 /// Carried across the user's devices. The default here, because a
47 /// notification preference is a preference.
48 #[default]
49 Synced,
50 /// Stays on this device.
51 Local,
52 }
53
54 #[cfg(feature = "synckit")]
55 impl Reach {
56 /// The same answer, in the type the sync boundary reads.
57 #[must_use]
58 pub const fn posture(self) -> synckit_config::Posture {
59 match self {
60 Self::Synced => synckit_config::Posture::Synced,
61 Self::Local => synckit_config::Posture::Local,
62 }
63 }
64 }
65
66 /// What a generated key currently holds.
67 ///
68 /// [`Setting`]'s counterpart on the reading side: a `Setting` is the type and
69 /// the default, declared once and `const`; a `Value` is one answer, owned,
70 /// which is what a store row and a settings control both hand back.
71 #[derive(Debug, Clone, PartialEq, Eq)]
72 pub enum Value {
73 /// On or off.
74 Toggle(bool),
75 /// A span, in seconds.
76 Seconds(i64),
77 /// A plain number.
78 Count(i64),
79 /// One of a fixed set. Checked against the offered options on the way in.
80 Choice(String),
81 }
82
83 impl Value {
84 /// How it is written in the store.
85 ///
86 /// `true`/`false` for a toggle, which is what goingson's existing config
87 /// rows already use, and the plain decimal for a number.
88 #[must_use]
89 pub fn text(&self) -> String {
90 match self {
91 Self::Toggle(on) => (*on).to_string(),
92 Self::Seconds(n) | Self::Count(n) => n.to_string(),
93 Self::Choice(picked) => picked.clone(),
94 }
95 }
96
97 /// Whether an on/off is on. `false` for anything that is not one.
98 #[must_use]
99 pub const fn is_on(&self) -> bool {
100 matches!(self, Self::Toggle(true))
101 }
102
103 /// The number, for the two variants that hold one.
104 #[must_use]
105 pub const fn number(&self) -> Option<i64> {
106 match self {
107 Self::Seconds(n) | Self::Count(n) => Some(*n),
108 _ => None,
109 }
110 }
111
112 /// What was picked, for the variant that picks.
113 #[must_use]
114 pub fn picked(&self) -> Option<&str> {
115 match self {
116 Self::Choice(picked) => Some(picked),
117 _ => None,
118 }
119 }
120
121 /// The default this setting declares.
122 #[must_use]
123 pub fn of(setting: Setting) -> Self {
124 match setting {
125 Setting::Toggle(on) => Self::Toggle(on),
126 Setting::Seconds(n) => Self::Seconds(n),
127 Setting::Count(n) => Self::Count(n),
128 Setting::Choice { default, .. } => Self::Choice(default.to_string()),
129 }
130 }
131
132 /// A stored string read as this setting's type, or `None` if it cannot be.
133 ///
134 /// A choice outside the offered options is `None` rather than itself: the
135 /// options are the question, so an answer that is not one of them is not a
136 /// stale preference to honour but a row to ignore.
137 #[must_use]
138 pub fn read(setting: Setting, stored: &str) -> Option<Self> {
139 match setting {
140 Setting::Toggle(_) => stored.parse().ok().map(Self::Toggle),
141 Setting::Seconds(_) => stored.parse().ok().map(Self::Seconds),
142 Setting::Count(_) => stored.parse().ok().map(Self::Count),
143 Setting::Choice { options, .. } => options
144 .contains(&stored)
145 .then(|| Self::Choice(stored.to_string())),
146 }
147 }
148 }
149
150 /// One key a declaration generates, with everything about it in one place.
151 ///
152 /// The point of the whole module: a key, its posture, and its default are one
153 /// value produced from one declaration, so there is no second place for any of
154 /// the three to be written differently.
155 #[derive(Debug, Clone, PartialEq, Eq)]
156 pub struct Generated {
157 /// The store key. `<kind>.enabled`, or `<kind>.<knob>`.
158 pub key: String,
159 /// The kind it belongs to.
160 pub kind: &'static str,
161 /// The knob, or `None` for the kind's own on/off.
162 pub knob: Option<&'static str>,
163 /// What the settings pane calls it.
164 pub label: &'static str,
165 /// Whether it crosses the sync boundary.
166 pub reach: Reach,
167 /// What it holds before anyone touches it.
168 pub default: Value,
169 }
170
171 /// Somewhere to read stored config values from.
172 ///
173 /// Deliberately one method and no writing. This crate produces the keys and
174 /// resolves the values; the store is the app's, and a trait that could write
175 /// would be this crate holding an opinion about transactions.
176 ///
177 /// A closure is one: `|key: &str| store.get(conn, key).ok().flatten()`.
178 pub trait Settings {
179 /// The stored value under `key`, if it has ever been set.
180 fn get(&self, key: &str) -> Option<String>;
181 }
182
183 impl<F> Settings for F
184 where
185 F: Fn(&str) -> Option<String>,
186 {
187 fn get(&self, key: &str) -> Option<String> {
188 self(key)
189 }
190 }
191
192 /// A map an app has already read the whole of.
193 ///
194 /// The blanket closure impl covers a store that is asked one key at a time. An
195 /// app that reads its config table in one query holds the answers before the
196 /// pane is drawn, and a closure over the map is a wrapper that says nothing:
197 /// GoingsOn's settings screen reads `all_config` once for the whole section.
198 /// Generic over the hasher, so a map built with a non-default one is covered
199 /// too: nothing here depends on how the map hashes.
200 impl<S: ::std::hash::BuildHasher> Settings for std::collections::HashMap<String, String, S> {
201 fn get(&self, key: &str) -> Option<String> {
202 self.get(key).cloned()
203 }
204 }
205
206 /// Nothing has been set. Every key reads as its default.
207 ///
208 /// What an app has on its very first run, and what a test wants when it is
209 /// asserting about defaults rather than about storage.
210 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
211 pub struct Unset;
212
213 impl Settings for Unset {
214 fn get(&self, _key: &str) -> Option<String> {
215 None
216 }
217 }
218
219 /// The key a kind's own on/off is stored under.
220 #[must_use]
221 pub fn enabled_key(kind: &str) -> String {
222 format!("{kind}.enabled")
223 }
224
225 /// The key one of a kind's knobs is stored under.
226 #[must_use]
227 pub fn knob_key(kind: &str, knob: &str) -> String {
228 format!("{kind}.{knob}")
229 }
230
231 impl Kind {
232 /// This kind's own on/off, as a generated key.
233 #[must_use]
234 pub fn enabled_config(&self) -> Generated {
235 Generated {
236 key: enabled_key(self.id),
237 kind: self.id,
238 knob: None,
239 label: self.title,
240 reach: self.reach,
241 default: Value::Toggle(matches!(self.ships, Posture::On)),
242 }
243 }
244
245 /// One of this kind's knobs, as a generated key.
246 #[must_use]
247 pub fn knob_config(&self, knob: &Knob) -> Generated {
248 Generated {
249 key: knob_key(self.id, knob.id),
250 kind: self.id,
251 knob: Some(knob.id),
252 label: knob.label,
253 reach: knob.reach,
254 default: Value::of(knob.value),
255 }
256 }
257
258 /// Every key this kind generates: its on/off, then its knobs in order.
259 pub fn config(&self) -> impl Iterator<Item = Generated> + '_ {
260 std::iter::once(self.enabled_config())
261 .chain(self.options.iter().map(|knob| self.knob_config(knob)))
262 }
263 }
264
265 impl Registry {
266 /// Every key the whole registry generates, in declaration order.
267 ///
268 /// The set an app's config surface is, and the set a test asserts is
269 /// complete.
270 pub fn config(&self) -> impl Iterator<Item = Generated> + '_ {
271 self.kinds().iter().flat_map(Kind::config)
272 }
273
274 /// Whether a kind is on, for this reader.
275 ///
276 /// **The question delivery asks before anything is sent.** An unknown kind
277 /// is off: a registry that does not declare it cannot say it should
278 /// interrupt anyone, and the fail-closed answer is the quiet one.
279 pub fn is_on(&self, kind: &str, settings: &impl Settings) -> bool {
280 self.value(kind, None, settings).is_some_and(|v| v.is_on())
281 }
282
283 /// What a knob currently holds, falling back to its declared default.
284 ///
285 /// Pass `None` for the knob to ask about the kind's own on/off. `None`
286 /// comes back only for a kind or knob the registry does not declare.
287 pub fn value(&self, kind: &str, knob: Option<&str>, settings: &impl Settings) -> Option<Value> {
288 let kind = self.kind(kind)?;
289 let (key, setting, fallback) = match knob {
290 None => (
291 enabled_key(kind.id),
292 Setting::Toggle(matches!(kind.ships, Posture::On)),
293 Value::Toggle(matches!(kind.ships, Posture::On)),
294 ),
295 Some(name) => {
296 let knob = kind.knob(name)?;
297 (
298 knob_key(kind.id, knob.id),
299 knob.value,
300 Value::of(knob.value),
301 )
302 }
303 };
304 Some(
305 settings
306 .get(&key)
307 .and_then(|stored| Value::read(setting, &stored))
308 .unwrap_or(fallback),
309 )
310 }
311
312 /// The postures the sync filter reads, one per generated key.
313 ///
314 /// The shape [`synckit_config::ConfigSpec::new`] takes, for an app that
315 /// builds its spec itself rather than taking [`spec`](Self::spec).
316 #[cfg(feature = "synckit")]
317 pub fn postures(&self) -> Vec<(String, synckit_config::Posture)> {
318 self.config()
319 .map(|generated| (generated.key, generated.reach.posture()))
320 .collect()
321 }
322
323 /// This registry's keys as a [`synckit_config::ConfigSpec`] over `table`.
324 ///
325 /// Produced rather than paralleled: the app declares its kinds and the spec
326 /// follows, so a new kind cannot arrive with its keys unclassified and
327 /// therefore silently `Local`.
328 ///
329 /// # It leaks, once, on purpose
330 ///
331 /// A `ConfigSpec` holds `&'static str` because an app's spec is a `const`.
332 /// A generated key is a `String`, so handing one to a spec means giving it
333 /// the `'static` lifetime it asks for. The registry is a `static` and its
334 /// key set is fixed at compile time, so what leaks is a bounded allocation
335 /// that would have lived for the process anyway.
336 ///
337 /// **Call it once** and keep the answer in a `OnceLock` or a `LazyLock`.
338 /// Calling it in a loop leaks per call, which is the one way to make this
339 /// cost anything.
340 #[cfg(feature = "synckit")]
341 #[must_use]
342 pub fn spec(&self, table: &'static str) -> synckit_config::ConfigSpec {
343 let keys: Vec<(&'static str, synckit_config::Posture)> = self
344 .config()
345 .map(|generated| {
346 let key: &'static str = Box::leak(generated.key.into_boxed_str());
347 (key, generated.reach.posture())
348 })
349 .collect();
350 synckit_config::ConfigSpec::new(table, Box::leak(keys.into_boxed_slice()))
351 }
352 }
353
354 #[cfg(test)]
355 mod tests {
356 use super::*;
357 use crate::{Kind, Knob, Registry, Setting};
358 use std::collections::HashMap;
359
360 static KINDS: &[Kind] = &[
361 Kind::new(
362 "snooze-expiry",
363 "Snoozed items resurface",
364 "When something you snoozed comes back.",
365 "Reminders",
366 )
367 .shipping_on(),
368 Kind::new(
369 "event-reminder",
370 "Event reminders",
371 "Before an event starts. Not the Events tab dot, which is `event_lead_minutes`.",
372 "Reminders",
373 )
374 .shipping_on()
375 .quiet_after_restart()
376 .with(&[
377 Knob::new("lead", "How long before", Setting::Seconds(900)),
378 Knob::new(
379 "sound",
380 "Sound",
381 Setting::Choice {
382 options: &["chime", "silent"],
383 default: "chime",
384 },
385 )
386 .on_this_device(),
387 ]),
388 Kind::new(
389 "digest",
390 "Daily digest",
391 "One summary of the day.",
392 "Summaries",
393 )
394 .with(&[Knob::new("items", "How many items", Setting::Count(10))]),
395 ];
396
397 static NOTIFS: Registry = Registry::new(KINDS);
398
399 fn stored(pairs: &[(&str, &str)]) -> impl Settings {
400 let map: HashMap<String, String> = pairs
401 .iter()
402 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
403 .collect();
404 move |key: &str| map.get(key).cloned()
405 }
406
407 #[test]
408 fn a_kind_generates_its_on_off_and_one_key_per_knob() {
409 let keys: Vec<String> = NOTIFS.config().map(|g| g.key).collect();
410 assert_eq!(
411 keys,
412 vec![
413 "snooze-expiry.enabled",
414 "event-reminder.enabled",
415 "event-reminder.lead",
416 "event-reminder.sound",
417 "digest.enabled",
418 "digest.items",
419 ]
420 );
421 }
422
423 #[test]
424 fn a_generated_key_cannot_collide_with_a_hand_written_one() {
425 // The collision this was filed in front of: goingson's
426 // `event_lead_minutes` is the Events tab dot, not a delivery setting.
427 // The dot is the separator and no id may contain one, so the generated
428 // lead time is `event-reminder.lead` and the two cannot be confused.
429 let generated: Vec<String> = NOTIFS.config().map(|g| g.key).collect();
430 assert!(!generated.iter().any(|k| k == "event_lead_minutes"));
431 assert!(generated.iter().all(|k| k.contains('.')));
432 }
433
434 #[test]
435 fn the_default_comes_from_the_declaration_and_from_nowhere_else() {
436 let by_key: HashMap<String, Value> = NOTIFS.config().map(|g| (g.key, g.default)).collect();
437 assert_eq!(by_key["snooze-expiry.enabled"], Value::Toggle(true));
438 assert_eq!(by_key["digest.enabled"], Value::Toggle(false));
439 assert_eq!(by_key["event-reminder.lead"], Value::Seconds(900));
440 assert_eq!(by_key["digest.items"], Value::Count(10));
441 assert_eq!(
442 by_key["event-reminder.sound"],
443 Value::Choice("chime".to_string())
444 );
445 }
446
447 #[test]
448 fn a_preference_syncs_and_a_machine_fact_does_not() {
449 let by_key: HashMap<String, Reach> = NOTIFS.config().map(|g| (g.key, g.reach)).collect();
450 assert_eq!(by_key["event-reminder.lead"], Reach::Synced);
451 assert_eq!(by_key["event-reminder.enabled"], Reach::Synced);
452 // Declared `on_this_device`: which sound this laptop plays is about
453 // this laptop.
454 assert_eq!(by_key["event-reminder.sound"], Reach::Local);
455 }
456
457 #[test]
458 fn a_stored_value_wins_over_the_default() {
459 let settings = stored(&[("digest.items", "3"), ("digest.enabled", "true")]);
460 assert_eq!(
461 NOTIFS.value("digest", Some("items"), &settings),
462 Some(Value::Count(3))
463 );
464 assert!(NOTIFS.is_on("digest", &settings));
465 }
466
467 #[test]
468 fn an_unset_key_reads_as_its_declared_default() {
469 assert_eq!(
470 NOTIFS.value("digest", Some("items"), &Unset),
471 Some(Value::Count(10))
472 );
473 assert!(!NOTIFS.is_on("digest", &Unset));
474 assert!(NOTIFS.is_on("snooze-expiry", &Unset));
475 }
476
477 #[test]
478 fn a_row_that_cannot_be_read_falls_back_rather_than_failing() {
479 // A pane that refuses to draw because one row is corrupt is worse than
480 // one that shows the default and lets the reader set it again.
481 let settings = stored(&[
482 ("digest.items", "not a number"),
483 ("event-reminder.sound", "foghorn"),
484 ]);
485 assert_eq!(
486 NOTIFS.value("digest", Some("items"), &settings),
487 Some(Value::Count(10))
488 );
489 assert_eq!(
490 NOTIFS.value("event-reminder", Some("sound"), &settings),
491 Some(Value::Choice("chime".to_string()))
492 );
493 }
494
495 #[test]
496 fn an_undeclared_kind_is_off_rather_than_absent() {
497 // Fail closed: the registry cannot say a kind it does not declare
498 // should interrupt anyone.
499 assert!(!NOTIFS.is_on("nope", &Unset));
500 assert_eq!(NOTIFS.value("nope", None, &Unset), None);
501 assert_eq!(NOTIFS.value("digest", Some("nope"), &Unset), None);
502 }
503
504 #[test]
505 fn every_value_round_trips_through_the_text_the_store_holds() {
506 for setting in [
507 Setting::Toggle(true),
508 Setting::Seconds(900),
509 Setting::Count(10),
510 Setting::Choice {
511 options: &["chime", "silent"],
512 default: "chime",
513 },
514 ] {
515 let value = Value::of(setting);
516 assert_eq!(Value::read(setting, &value.text()), Some(value));
517 }
518 }
519
520 #[cfg(feature = "synckit")]
521 #[test]
522 fn the_spec_is_produced_from_the_declaration_rather_than_written_beside_it() {
523 let spec = NOTIFS.spec("user_config");
524 assert_eq!(spec.table(), "user_config");
525 assert!(spec.is_synced("event-reminder.lead"));
526 assert!(!spec.is_synced("event-reminder.sound"));
527 // Fail-closed, inherited: a key no declaration produced never syncs.
528 assert!(!spec.is_synced("event-reminder.whatever"));
529 assert_eq!(spec.keys().count(), NOTIFS.config().count());
530 }
531 }
532