//! The configuration a declaration generates. //! //! A [`Kind`] is a settings surface that has not been written down yet: an //! on/off, one key per knob, a default for each, and a posture saying whether //! each travels between a user's devices. //! //! # The generated key is dotted, and that is what keeps it apart //! //! `.enabled`, and `.` for each option. `is_key` refuses a //! dot in either id for exactly this reason: the dot is the separator, so it //! cannot also be inside a name. //! //! It also settles the collision this task was filed in front of. goingson //! already has `event_lead_minutes`, and it is **not** a delivery setting -- //! its hint reads "How far in advance the Events tab dot turns yellow", so it //! is about a coloured dot in a tab. The event-reminder kind wants a lead time //! too, and named the same way the two would sit next to each other in one //! table meaning different things. Generated keys are dotted and hand-written //! app keys are not, so `event-reminder.lead` cannot be mistaken for it in the //! store, in the policy table, or by a reader. The pane still owes the //! distinction in words, which is the kind's [`summary`](Kind::summary). //! //! # Values are text, because the store is //! //! [`ConfigStore`](https://makenot.work/git/max/synckit) holds `String`s. So a //! [`Value`] renders to text and parses back from it, and every default in this //! crate has exactly one written form. An unparseable stored value reads as the //! default rather than as an error: a settings pane that refuses to draw //! because one row is corrupt is worse than one that shows the default and lets //! the reader set it again. use crate::{Kind, Knob, Posture, Registry, Setting}; /// Whether a generated key may be replicated to the user's other devices. /// /// [`synckit_config::Posture`] under another name, and the name is forced: /// [`Posture`] here is already whether a kind ships on. The mapping is one to /// one and `Reach::posture` (under the `synckit` feature) is it, so nothing chooses between two vocabularies /// -- this crate cannot depend on `synckit-config` unconditionally, because that /// crate carries a bundled SQLite and an app that only declares kinds should not /// link one. /// /// [`synckit_config::Posture`]: https://makenot.work/git/max/synckit #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum Reach { /// Carried across the user's devices. The default here, because a /// notification preference is a preference. #[default] Synced, /// Stays on this device. Local, } #[cfg(feature = "synckit")] impl Reach { /// The same answer, in the type the sync boundary reads. #[must_use] pub const fn posture(self) -> synckit_config::Posture { match self { Self::Synced => synckit_config::Posture::Synced, Self::Local => synckit_config::Posture::Local, } } } /// What a generated key currently holds. /// /// [`Setting`]'s counterpart on the reading side: a `Setting` is the type and /// the default, declared once and `const`; a `Value` is one answer, owned, /// which is what a store row and a settings control both hand back. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Value { /// On or off. Toggle(bool), /// A span, in seconds. Seconds(i64), /// A plain number. Count(i64), /// One of a fixed set. Checked against the offered options on the way in. Choice(String), } impl Value { /// How it is written in the store. /// /// `true`/`false` for a toggle, which is what goingson's existing config /// rows already use, and the plain decimal for a number. #[must_use] pub fn text(&self) -> String { match self { Self::Toggle(on) => (*on).to_string(), Self::Seconds(n) | Self::Count(n) => n.to_string(), Self::Choice(picked) => picked.clone(), } } /// Whether an on/off is on. `false` for anything that is not one. #[must_use] pub const fn is_on(&self) -> bool { matches!(self, Self::Toggle(true)) } /// The number, for the two variants that hold one. #[must_use] pub const fn number(&self) -> Option { match self { Self::Seconds(n) | Self::Count(n) => Some(*n), _ => None, } } /// What was picked, for the variant that picks. #[must_use] pub fn picked(&self) -> Option<&str> { match self { Self::Choice(picked) => Some(picked), _ => None, } } /// The default this setting declares. #[must_use] pub fn of(setting: Setting) -> Self { match setting { Setting::Toggle(on) => Self::Toggle(on), Setting::Seconds(n) => Self::Seconds(n), Setting::Count(n) => Self::Count(n), Setting::Choice { default, .. } => Self::Choice(default.to_string()), } } /// A stored string read as this setting's type, or `None` if it cannot be. /// /// A choice outside the offered options is `None` rather than itself: the /// options are the question, so an answer that is not one of them is not a /// stale preference to honour but a row to ignore. #[must_use] pub fn read(setting: Setting, stored: &str) -> Option { match setting { Setting::Toggle(_) => stored.parse().ok().map(Self::Toggle), Setting::Seconds(_) => stored.parse().ok().map(Self::Seconds), Setting::Count(_) => stored.parse().ok().map(Self::Count), Setting::Choice { options, .. } => options .contains(&stored) .then(|| Self::Choice(stored.to_string())), } } } /// One key a declaration generates, with everything about it in one place. /// /// The point of the whole module: a key, its posture, and its default are one /// value produced from one declaration, so there is no second place for any of /// the three to be written differently. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Generated { /// The store key. `.enabled`, or `.`. pub key: String, /// The kind it belongs to. pub kind: &'static str, /// The knob, or `None` for the kind's own on/off. pub knob: Option<&'static str>, /// What the settings pane calls it. pub label: &'static str, /// Whether it crosses the sync boundary. pub reach: Reach, /// What it holds before anyone touches it. pub default: Value, } /// Somewhere to read stored config values from. /// /// Deliberately one method and no writing. This crate produces the keys and /// resolves the values; the store is the app's, and a trait that could write /// would be this crate holding an opinion about transactions. /// /// A closure is one: `|key: &str| store.get(conn, key).ok().flatten()`. pub trait Settings { /// The stored value under `key`, if it has ever been set. fn get(&self, key: &str) -> Option; } impl Settings for F where F: Fn(&str) -> Option, { fn get(&self, key: &str) -> Option { self(key) } } /// A map an app has already read the whole of. /// /// The blanket closure impl covers a store that is asked one key at a time. An /// app that reads its config table in one query holds the answers before the /// pane is drawn, and a closure over the map is a wrapper that says nothing: /// GoingsOn's settings screen reads `all_config` once for the whole section. /// Generic over the hasher, so a map built with a non-default one is covered /// too: nothing here depends on how the map hashes. impl Settings for std::collections::HashMap { fn get(&self, key: &str) -> Option { self.get(key).cloned() } } /// Nothing has been set. Every key reads as its default. /// /// What an app has on its very first run, and what a test wants when it is /// asserting about defaults rather than about storage. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Unset; impl Settings for Unset { fn get(&self, _key: &str) -> Option { None } } /// The key a kind's own on/off is stored under. #[must_use] pub fn enabled_key(kind: &str) -> String { format!("{kind}.enabled") } /// The key one of a kind's knobs is stored under. #[must_use] pub fn knob_key(kind: &str, knob: &str) -> String { format!("{kind}.{knob}") } impl Kind { /// This kind's own on/off, as a generated key. #[must_use] pub fn enabled_config(&self) -> Generated { Generated { key: enabled_key(self.id), kind: self.id, knob: None, label: self.title, reach: self.reach, default: Value::Toggle(matches!(self.ships, Posture::On)), } } /// One of this kind's knobs, as a generated key. #[must_use] pub fn knob_config(&self, knob: &Knob) -> Generated { Generated { key: knob_key(self.id, knob.id), kind: self.id, knob: Some(knob.id), label: knob.label, reach: knob.reach, default: Value::of(knob.value), } } /// Every key this kind generates: its on/off, then its knobs in order. pub fn config(&self) -> impl Iterator + '_ { std::iter::once(self.enabled_config()) .chain(self.options.iter().map(|knob| self.knob_config(knob))) } } impl Registry { /// Every key the whole registry generates, in declaration order. /// /// The set an app's config surface is, and the set a test asserts is /// complete. pub fn config(&self) -> impl Iterator + '_ { self.kinds().iter().flat_map(Kind::config) } /// Whether a kind is on, for this reader. /// /// **The question delivery asks before anything is sent.** An unknown kind /// is off: a registry that does not declare it cannot say it should /// interrupt anyone, and the fail-closed answer is the quiet one. pub fn is_on(&self, kind: &str, settings: &impl Settings) -> bool { self.value(kind, None, settings).is_some_and(|v| v.is_on()) } /// What a knob currently holds, falling back to its declared default. /// /// Pass `None` for the knob to ask about the kind's own on/off. `None` /// comes back only for a kind or knob the registry does not declare. pub fn value(&self, kind: &str, knob: Option<&str>, settings: &impl Settings) -> Option { let kind = self.kind(kind)?; let (key, setting, fallback) = match knob { None => ( enabled_key(kind.id), Setting::Toggle(matches!(kind.ships, Posture::On)), Value::Toggle(matches!(kind.ships, Posture::On)), ), Some(name) => { let knob = kind.knob(name)?; ( knob_key(kind.id, knob.id), knob.value, Value::of(knob.value), ) } }; Some( settings .get(&key) .and_then(|stored| Value::read(setting, &stored)) .unwrap_or(fallback), ) } /// The postures the sync filter reads, one per generated key. /// /// The shape [`synckit_config::ConfigSpec::new`] takes, for an app that /// builds its spec itself rather than taking [`spec`](Self::spec). #[cfg(feature = "synckit")] pub fn postures(&self) -> Vec<(String, synckit_config::Posture)> { self.config() .map(|generated| (generated.key, generated.reach.posture())) .collect() } /// This registry's keys as a [`synckit_config::ConfigSpec`] over `table`. /// /// Produced rather than paralleled: the app declares its kinds and the spec /// follows, so a new kind cannot arrive with its keys unclassified and /// therefore silently `Local`. /// /// # It leaks, once, on purpose /// /// A `ConfigSpec` holds `&'static str` because an app's spec is a `const`. /// A generated key is a `String`, so handing one to a spec means giving it /// the `'static` lifetime it asks for. The registry is a `static` and its /// key set is fixed at compile time, so what leaks is a bounded allocation /// that would have lived for the process anyway. /// /// **Call it once** and keep the answer in a `OnceLock` or a `LazyLock`. /// Calling it in a loop leaks per call, which is the one way to make this /// cost anything. #[cfg(feature = "synckit")] #[must_use] pub fn spec(&self, table: &'static str) -> synckit_config::ConfigSpec { let keys: Vec<(&'static str, synckit_config::Posture)> = self .config() .map(|generated| { let key: &'static str = Box::leak(generated.key.into_boxed_str()); (key, generated.reach.posture()) }) .collect(); synckit_config::ConfigSpec::new(table, Box::leak(keys.into_boxed_slice())) } } #[cfg(test)] mod tests { use super::*; use crate::{Kind, Knob, Registry, Setting}; use std::collections::HashMap; static KINDS: &[Kind] = &[ Kind::new( "snooze-expiry", "Snoozed items resurface", "When something you snoozed comes back.", "Reminders", ) .shipping_on(), Kind::new( "event-reminder", "Event reminders", "Before an event starts. Not the Events tab dot, which is `event_lead_minutes`.", "Reminders", ) .shipping_on() .quiet_after_restart() .with(&[ Knob::new("lead", "How long before", Setting::Seconds(900)), Knob::new( "sound", "Sound", Setting::Choice { options: &["chime", "silent"], default: "chime", }, ) .on_this_device(), ]), Kind::new( "digest", "Daily digest", "One summary of the day.", "Summaries", ) .with(&[Knob::new("items", "How many items", Setting::Count(10))]), ]; static NOTIFS: Registry = Registry::new(KINDS); fn stored(pairs: &[(&str, &str)]) -> impl Settings { let map: HashMap = pairs .iter() .map(|(k, v)| ((*k).to_string(), (*v).to_string())) .collect(); move |key: &str| map.get(key).cloned() } #[test] fn a_kind_generates_its_on_off_and_one_key_per_knob() { let keys: Vec = NOTIFS.config().map(|g| g.key).collect(); assert_eq!( keys, vec![ "snooze-expiry.enabled", "event-reminder.enabled", "event-reminder.lead", "event-reminder.sound", "digest.enabled", "digest.items", ] ); } #[test] fn a_generated_key_cannot_collide_with_a_hand_written_one() { // The collision this was filed in front of: goingson's // `event_lead_minutes` is the Events tab dot, not a delivery setting. // The dot is the separator and no id may contain one, so the generated // lead time is `event-reminder.lead` and the two cannot be confused. let generated: Vec = NOTIFS.config().map(|g| g.key).collect(); assert!(!generated.iter().any(|k| k == "event_lead_minutes")); assert!(generated.iter().all(|k| k.contains('.'))); } #[test] fn the_default_comes_from_the_declaration_and_from_nowhere_else() { let by_key: HashMap = NOTIFS.config().map(|g| (g.key, g.default)).collect(); assert_eq!(by_key["snooze-expiry.enabled"], Value::Toggle(true)); assert_eq!(by_key["digest.enabled"], Value::Toggle(false)); assert_eq!(by_key["event-reminder.lead"], Value::Seconds(900)); assert_eq!(by_key["digest.items"], Value::Count(10)); assert_eq!( by_key["event-reminder.sound"], Value::Choice("chime".to_string()) ); } #[test] fn a_preference_syncs_and_a_machine_fact_does_not() { let by_key: HashMap = NOTIFS.config().map(|g| (g.key, g.reach)).collect(); assert_eq!(by_key["event-reminder.lead"], Reach::Synced); assert_eq!(by_key["event-reminder.enabled"], Reach::Synced); // Declared `on_this_device`: which sound this laptop plays is about // this laptop. assert_eq!(by_key["event-reminder.sound"], Reach::Local); } #[test] fn a_stored_value_wins_over_the_default() { let settings = stored(&[("digest.items", "3"), ("digest.enabled", "true")]); assert_eq!( NOTIFS.value("digest", Some("items"), &settings), Some(Value::Count(3)) ); assert!(NOTIFS.is_on("digest", &settings)); } #[test] fn an_unset_key_reads_as_its_declared_default() { assert_eq!( NOTIFS.value("digest", Some("items"), &Unset), Some(Value::Count(10)) ); assert!(!NOTIFS.is_on("digest", &Unset)); assert!(NOTIFS.is_on("snooze-expiry", &Unset)); } #[test] fn a_row_that_cannot_be_read_falls_back_rather_than_failing() { // A pane that refuses to draw because one row is corrupt is worse than // one that shows the default and lets the reader set it again. let settings = stored(&[ ("digest.items", "not a number"), ("event-reminder.sound", "foghorn"), ]); assert_eq!( NOTIFS.value("digest", Some("items"), &settings), Some(Value::Count(10)) ); assert_eq!( NOTIFS.value("event-reminder", Some("sound"), &settings), Some(Value::Choice("chime".to_string())) ); } #[test] fn an_undeclared_kind_is_off_rather_than_absent() { // Fail closed: the registry cannot say a kind it does not declare // should interrupt anyone. assert!(!NOTIFS.is_on("nope", &Unset)); assert_eq!(NOTIFS.value("nope", None, &Unset), None); assert_eq!(NOTIFS.value("digest", Some("nope"), &Unset), None); } #[test] fn every_value_round_trips_through_the_text_the_store_holds() { for setting in [ Setting::Toggle(true), Setting::Seconds(900), Setting::Count(10), Setting::Choice { options: &["chime", "silent"], default: "chime", }, ] { let value = Value::of(setting); assert_eq!(Value::read(setting, &value.text()), Some(value)); } } #[cfg(feature = "synckit")] #[test] fn the_spec_is_produced_from_the_declaration_rather_than_written_beside_it() { let spec = NOTIFS.spec("user_config"); assert_eq!(spec.table(), "user_config"); assert!(spec.is_synced("event-reminder.lead")); assert!(!spec.is_synced("event-reminder.sound")); // Fail-closed, inherited: a key no declaration produced never syncs. assert!(!spec.is_synced("event-reminder.whatever")); assert_eq!(spec.keys().count(), NOTIFS.config().count()); } }