//! What a notification says and what it is for, apart from how it reaches a //! person. //! //! //! //! The same three questions — is this kind on, what are its knobs, when does it //! fire — have to be answered on a webview, a terminal and egui. That is the //! argument every other quasi member rests on, and it is why this is a crate //! rather than a module inside one app: a notification is a described artifact, //! and the delivery is the renderer's business. //! //! # This crate is the declaration only //! //! A [`Kind`] and the [`Registry`] that holds them. It generates no //! configuration and delivers nothing; those are two separate pieces of work, //! and keeping them apart is what stops the declaration from growing a //! dependency on a host. //! //! # A kind is app-wide, and a per-row switch is the app's //! //! A [`Kind`] is one switch for a whole app: its generated `.enabled` //! key answers on or off once, for everybody using that install. A //! notification whose on/off is answered per row of something the app owns, //! per email account, per project, per calendar, is the app's own switch and //! its own call to make. It is not declared here. //! //! What that costs, so the next reader does not take it for an oversight: a //! notification the app fires itself goes out without the [`Outbox`], so //! duplicate suppression and [`CatchUp`] do not cover it. //! //! This records where the line sits rather than closing the question. Measured //! across the tree, exactly one per-row toggle exists, goingson's //! `email_accounts.notify_new_emails`, and for that one the suppression is not //! a loss: two sync cycles that both save mail are two notifications a reader //! wants, and a restart syncs and produces fresh mail anyway, so there is //! nothing for [`CatchUp`] to catch up. A second per-row kind would change //! that count and be worth reopening this on. //! //! # Const-friendly on purpose //! //! Every type here is constructible in a `const`, so an app's notification set //! is a `static` it can point at rather than a builder it has to run at //! startup. A registry that has to be built is a registry that can be built //! twice, differently, in two places. //! //! ``` //! use quasi_notifs::{Kind, Knob, Registry, Setting}; //! //! 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.", "Reminders") //! .shipping_on() //! .with(&[Knob::new("lead", "How long before", Setting::Seconds(900))]), //! ]; //! //! static NOTIFS: Registry = Registry::new(KINDS); //! assert!(NOTIFS.check().is_ok()); //! ``` pub mod config; pub mod deliver; #[cfg(feature = "tauri")] pub mod notify; #[cfg(feature = "describe")] pub mod pane; pub use config::{Generated, Reach, Settings, Value}; pub use deliver::{Deliver, Occurrence, Outbox, Sweep}; #[cfg(feature = "tauri")] pub use notify::Notifier; /// Whether a kind fires for someone who has never touched its settings. /// /// The framework default is [`Off`](Self::Off): a notification nobody asked /// for is an interruption nobody asked for, and onboarding is what points at /// the ones that ship quiet. /// /// A kind that already fires in a shipped app declares [`On`](Self::On), so /// adopting the framework does not silently stop a notification somebody /// depends on today. That is a migration fact rather than a preference, and it /// is one line per kind either way. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Posture { /// Fires unless the reader turns it off. On, /// Silent unless the reader turns it on. Off, } /// What a restart owes the occurrences that came due while the app was shut. /// /// Measured on goingson's watcher, which answers this twice and differently. /// A snooze that expired overnight is still expired and the reader still wants /// it back, so it fires: [`Fire`](Self::Fire). An event reminder for a meeting /// that started an hour ago is an interruption about something already missed, /// and the shipped watcher bootstraps those away on its first tick: /// [`Skip`](Self::Skip). /// /// It is on the declaration rather than on the host because it is a fact about /// what the kind *means* -- whether the occurrence keeps its worth once it is /// late -- and every renderer would otherwise answer it separately and /// differently. [`Fire`](Self::Fire) is the default because it is the answer /// that loses nothing; a kind that would interrupt about a moment that has /// passed says so. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum CatchUp { /// Deliver it late. It kept its worth. #[default] Fire, /// Note it as seen and say nothing. The moment has passed. Skip, } /// What a knob holds, and what it holds before anyone touches it. /// /// The type and the default are one value rather than two members, so a knob /// cannot carry a default of the wrong type. That is the same reason /// [`Kind::ships`] is a [`Posture`] and not a `bool` beside a comment. /// /// # What is deliberately not here /// /// A list-valued knob. goingson's event reminders take several lead times, and /// they are a property of the *event* (`event.reminder_offsets_seconds`) rather /// than of the kind, so no measured site wants one here. Adding a variant for a /// shape nothing has asked for is how a vocabulary drifts, and it is one /// variant whenever something does. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Setting { /// On or off, and which it starts as. Toggle(bool), /// A span, in seconds. A lead time. Seconds(i64), /// A plain number. A threshold, a count. Count(i64), /// One of a fixed set. /// /// `default` has to be one of `options`; [`Registry::check`] is what says /// so, because a `const` cannot. Choice { /// What may be chosen, in the order they are offered. options: &'static [&'static str], /// What is chosen before anyone chooses. default: &'static str, }, } /// One granular knob belonging to one kind. /// /// The half of "automatically generating granular configuration" that carries /// the weight: a kind with no knobs generates a single on/off, and a kind with /// knobs generates a section. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Knob { /// The config key stem, under the kind's own. /// /// Same shape rule as [`Kind::id`]: unprefixed, because the kind it belongs /// to is already the prefix. pub id: &'static str, /// What the settings pane calls it. pub label: &'static str, /// What it holds, and what it holds by default. pub value: Setting, /// Whether this knob's generated key travels between a user's devices. /// /// [`Reach::Synced`] by default, because a notification preference is a /// preference and answering it twice on two machines is the thing config /// sync exists to stop. See [`Knob::on_this_device`] for the exception. pub reach: Reach, } impl Knob { /// A knob of the given kind of value, synced. #[must_use] pub const fn new(id: &'static str, label: &'static str, value: Setting) -> Self { Self { id, label, value, reach: Reach::Synced, } } /// This knob's answer is about one machine, chaining. /// /// The narrow case: a knob whose answer would be wrong on the other device /// rather than merely unset there. A quiet-hours knob is a preference and /// syncs; "which sound this laptop plays" is about this laptop. #[must_use] pub const fn on_this_device(mut self) -> Self { self.reach = Reach::Local; self } } /// One kind of notification, declared once. /// /// A kind is not an instance. "Snoozed items resurface" is a kind; the /// notification about the one task that came back at 4pm is not, and nothing /// here describes it. What this carries is everything a settings pane, an /// onboarding pointer and a config file need, which is exactly the set that /// three renderers would otherwise each answer differently. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Kind { /// The stable name, and the config key stem. /// /// Unprefixed by the app: `snooze-expiry`, never `goingson-snooze-expiry`. /// The registry belongs to one app already, and a key that repeats the app /// name is a key that has to be rewritten when the app is renamed. /// [`Registry::check`] enforces the shape. pub id: &'static str, /// What the settings pane calls it. pub title: &'static str, /// One line saying what it is for. /// /// What the pane renders under the title, and what onboarding points at. /// One line because two is a paragraph nobody reads in a list of twenty. pub summary: &'static str, /// What it groups under in the generated pane. /// /// A plain string rather than an enum: the categories are the app's, and a /// closed set here would mean this crate deciding what kinds of /// notification exist. pub category: &'static str, /// Whether it fires for someone who has never touched it. pub ships: Posture, /// Whether this kind's generated `enabled` key travels between a user's /// devices. [`Reach::Synced`] by default, as [`Knob::reach`] is. pub reach: Reach, /// What a restart owes the occurrences that came due while the app was shut. pub restart: CatchUp, /// The knobs this kind has, in the order they are offered. pub options: &'static [Knob], } impl Kind { /// A kind that ships off and has no knobs. /// /// Off because that is the framework default and the thing a declaration /// should have to say out loud is the interruption, not the silence. #[must_use] pub const fn new( id: &'static str, title: &'static str, summary: &'static str, category: &'static str, ) -> Self { Self { id, title, summary, category, ships: Posture::Off, reach: Reach::Synced, restart: CatchUp::Fire, options: &[], } } /// This one fires unless it is turned off, chaining. #[must_use] pub const fn shipping_on(mut self) -> Self { self.ships = Posture::On; self } /// The knobs this kind offers, chaining. #[must_use] pub const fn with(mut self, options: &'static [Knob]) -> Self { self.options = options; self } /// Whether this kind is on is a fact about one machine, chaining. /// /// Rare, and it should be: turning a kind off on the laptop and being /// interrupted by it on the tablet is the state the default avoids. #[must_use] pub const fn on_this_device(mut self) -> Self { self.reach = Reach::Local; self } /// What was due while the app was shut has passed, chaining. /// /// See [`CatchUp::Skip`], which this selects. #[must_use] pub const fn quiet_after_restart(mut self) -> Self { self.restart = CatchUp::Skip; self } /// The knob under this kind with the given id. #[must_use] pub fn knob(&self, id: &str) -> Option<&Knob> { self.options.iter().find(|knob| knob.id == id) } } /// An app's whole set of notification kinds. /// /// Declared once and handed to the host. One per app: a second registry is two /// answers to "what can this app notify me about", and the settings pane can /// only render one of them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Registry { kinds: &'static [Kind], } impl Registry { /// The registry holding these kinds. #[must_use] pub const fn new(kinds: &'static [Kind]) -> Self { Self { kinds } } /// Every kind, in declaration order. #[must_use] pub const fn kinds(&self) -> &'static [Kind] { self.kinds } /// The kind with the given id. #[must_use] pub fn kind(&self, id: &str) -> Option<&Kind> { self.kinds.iter().find(|kind| kind.id == id) } /// Every category present, in the order its first kind declared it. /// /// Declaration order rather than alphabetical, because the app grouped /// these deliberately and sorting would replace its judgment with the /// alphabet's. #[must_use] pub fn categories(&self) -> Vec<&'static str> { let mut seen: Vec<&'static str> = Vec::new(); for kind in self.kinds { if !seen.contains(&kind.category) { seen.push(kind.category); } } seen } /// What is wrong with this registry, if anything is. /// /// Everything a `const` cannot check. Call it from a test rather than at /// startup: a registry is a compile-time constant, so a failure here is a /// failure of the source and not of the run, and a check that only fires in /// production is a check that ships broken. /// /// # Errors /// /// The first [`Fault`] found, in the order the kinds were declared. pub fn check(&self) -> Result<(), Fault> { let mut seen: Vec<&'static str> = Vec::new(); for kind in self.kinds { if !is_key(kind.id) { return Err(Fault::BadId { id: kind.id }); } if seen.contains(&kind.id) { return Err(Fault::TwoKinds { id: kind.id }); } seen.push(kind.id); let mut knobs: Vec<&'static str> = Vec::new(); for knob in kind.options { if !is_key(knob.id) { return Err(Fault::BadId { id: knob.id }); } if knobs.contains(&knob.id) { return Err(Fault::TwoKnobs { kind: kind.id, id: knob.id, }); } knobs.push(knob.id); if let Setting::Choice { options, default } = knob.value && !options.contains(&default) { return Err(Fault::DefaultNotOffered { kind: kind.id, id: knob.id, }); } } } Ok(()) } } /// Whether a string can be a config key stem. /// /// Lowercase, digits and hyphens. No dots, because a dot is what separates a /// kind's key from a knob's and an id containing one would make the generated /// key ambiguous about which is which. fn is_key(id: &str) -> bool { !id.is_empty() && id .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } /// Something wrong with a registry that a `const` could not catch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Fault { /// An id that cannot be a config key stem. BadId { /// The id as declared. id: &'static str, }, /// Two kinds under one id, so their settings would share a key. TwoKinds { /// The repeated id. id: &'static str, }, /// Two knobs under one id within a kind. TwoKnobs { /// The kind holding both. kind: &'static str, /// The repeated id. id: &'static str, }, /// A [`Setting::Choice`] whose default is not one of its options. DefaultNotOffered { /// The kind holding the knob. kind: &'static str, /// The knob. id: &'static str, }, } impl std::fmt::Display for Fault { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::BadId { id } => { write!( f, "`{id}` cannot be a config key: lowercase, digits and hyphens only" ) } Self::TwoKinds { id } => write!(f, "two kinds are declared as `{id}`"), Self::TwoKnobs { kind, id } => { write!(f, "`{kind}` declares two knobs as `{id}`") } Self::DefaultNotOffered { kind, id } => { write!(f, "`{kind}.{id}` defaults to something it does not offer") } } } } impl std::error::Error for Fault {} #[cfg(test)] mod tests { use super::*; /// A registry of two kinds, which is the smallest one that can be wrong in /// an interesting way: one kind cannot collide, and one knob cannot either. static KINDS: &[Kind] = &[ Kind::new( "snooze-expiry", "Snoozed items resurface", "When something you snoozed comes back.", "Reminders", ) .shipping_on(), Kind::new( "digest", "Daily digest", "One summary of the day, at a time you pick.", "Summaries", ) .with(&[ Knob::new("at", "When", Setting::Seconds(9 * 3600)), Knob::new( "include", "What to include", Setting::Choice { options: &["everything", "overdue only"], default: "overdue only", }, ), ]), ]; static NOTIFS: Registry = Registry::new(KINDS); #[test] fn a_registry_is_a_constant_and_a_valid_one() { // The whole point of const-friendly data: this is a `static`, not // something a startup path built. If that stops being true the // declaration can differ between two places that build it. assert!(NOTIFS.check().is_ok()); assert_eq!(NOTIFS.kinds().len(), 2); } #[test] fn a_kind_ships_off_unless_it_says_otherwise() { // Framework default, decided 2026-08-17. A declaration should have to // say the interruption out loud, not the silence. assert_eq!(NOTIFS.kind("digest").unwrap().ships, Posture::Off); assert_eq!(NOTIFS.kind("snooze-expiry").unwrap().ships, Posture::On); } #[test] fn a_knob_carries_its_type_and_its_default_as_one_value() { let digest = NOTIFS.kind("digest").unwrap(); assert_eq!(digest.knob("at").unwrap().value, Setting::Seconds(9 * 3600)); assert_eq!(digest.options.len(), 2); // A kind with no knobs generates a single on/off rather than a section. assert!(NOTIFS.kind("snooze-expiry").unwrap().options.is_empty()); } #[test] fn categories_keep_the_order_the_app_declared_them_in() { // Not sorted. The app grouped these deliberately and the alphabet has // no opinion worth substituting for that. assert_eq!(NOTIFS.categories(), vec!["Reminders", "Summaries"]); } #[test] fn an_unknown_id_is_absent_rather_than_a_panic() { assert!(NOTIFS.kind("nope").is_none()); assert!(NOTIFS.kind("digest").unwrap().knob("nope").is_none()); } #[test] fn two_kinds_under_one_id_would_share_a_config_key() { static CLASH: &[Kind] = &[ Kind::new("digest", "One", "First.", "A"), Kind::new("digest", "Two", "Second.", "B"), ]; assert_eq!( Registry::new(CLASH).check(), Err(Fault::TwoKinds { id: "digest" }) ); } #[test] fn two_knobs_under_one_id_would_too() { static CLASH: &[Kind] = &[Kind::new("digest", "Digest", "Daily.", "A").with(&[ Knob::new("at", "When", Setting::Seconds(0)), Knob::new("at", "Also when", Setting::Count(0)), ])]; assert_eq!( Registry::new(CLASH).check(), Err(Fault::TwoKnobs { kind: "digest", id: "at" }) ); } #[test] fn an_id_that_cannot_be_a_config_key_is_refused() { // The family convention: `theme`, not `goingson-theme`, and never a // dot -- a dot is what separates a kind's key from a knob's. for bad in ["Digest", "daily.digest", "daily digest", ""] { let kinds: &'static [Kind] = Box::leak(Box::new([Kind::new(bad, "T", "S", "C")])); assert_eq!( Registry::new(kinds).check(), Err(Fault::BadId { id: bad }), "{bad:?} should be refused" ); } // A hyphenated stem is the shape the shipped kinds already use. static GOOD: &[Kind] = &[Kind::new("snooze-expiry", "T", "S", "C")]; assert!(Registry::new(GOOD).check().is_ok()); } #[test] fn a_choice_cannot_default_to_something_it_does_not_offer() { static BAD: &[Kind] = &[ Kind::new("digest", "Digest", "Daily.", "A").with(&[Knob::new( "include", "What", Setting::Choice { options: &["everything"], default: "overdue only", }, )]), ]; assert_eq!( Registry::new(BAD).check(), Err(Fault::DefaultNotOffered { kind: "digest", id: "include" }) ); } #[test] fn a_fault_says_which_declaration_is_wrong() { // The message is read by whoever wrote the registry, so it names the // ids rather than the position in a slice. assert!( Fault::TwoKnobs { kind: "digest", id: "at" } .to_string() .contains("`digest` declares two knobs as `at`") ); } }