Skip to main content

max / quasi

21.3 KB · 609 lines History Blame Raw
1 //! What a notification says and what it is for, apart from how it reaches a
2 //! person.
3 //!
4 //! <!-- wiki: quasi-overview -->
5 //!
6 //! The same three questions — is this kind on, what are its knobs, when does it
7 //! fire — have to be answered on a webview, a terminal and egui. That is the
8 //! argument every other quasi member rests on, and it is why this is a crate
9 //! rather than a module inside one app: a notification is a described artifact,
10 //! and the delivery is the renderer's business.
11 //!
12 //! # This crate is the declaration only
13 //!
14 //! A [`Kind`] and the [`Registry`] that holds them. It generates no
15 //! configuration and delivers nothing; those are two separate pieces of work,
16 //! and keeping them apart is what stops the declaration from growing a
17 //! dependency on a host.
18 //!
19 //! # A kind is app-wide, and a per-row switch is the app's
20 //!
21 //! A [`Kind`] is one switch for a whole app: its generated `<kind>.enabled`
22 //! key answers on or off once, for everybody using that install. A
23 //! notification whose on/off is answered per row of something the app owns,
24 //! per email account, per project, per calendar, is the app's own switch and
25 //! its own call to make. It is not declared here.
26 //!
27 //! What that costs, so the next reader does not take it for an oversight: a
28 //! notification the app fires itself goes out without the [`Outbox`], so
29 //! duplicate suppression and [`CatchUp`] do not cover it.
30 //!
31 //! This records where the line sits rather than closing the question. Measured
32 //! across the tree, exactly one per-row toggle exists, goingson's
33 //! `email_accounts.notify_new_emails`, and for that one the suppression is not
34 //! a loss: two sync cycles that both save mail are two notifications a reader
35 //! wants, and a restart syncs and produces fresh mail anyway, so there is
36 //! nothing for [`CatchUp`] to catch up. A second per-row kind would change
37 //! that count and be worth reopening this on.
38 //!
39 //! # Const-friendly on purpose
40 //!
41 //! Every type here is constructible in a `const`, so an app's notification set
42 //! is a `static` it can point at rather than a builder it has to run at
43 //! startup. A registry that has to be built is a registry that can be built
44 //! twice, differently, in two places.
45 //!
46 //! ```
47 //! use quasi_notifs::{Kind, Knob, Registry, Setting};
48 //!
49 //! static KINDS: &[Kind] = &[
50 //! Kind::new("snooze-expiry", "Snoozed items resurface", "When something you snoozed comes back.", "Reminders")
51 //! .shipping_on(),
52 //! Kind::new("event-reminder", "Event reminders", "Before an event starts.", "Reminders")
53 //! .shipping_on()
54 //! .with(&[Knob::new("lead", "How long before", Setting::Seconds(900))]),
55 //! ];
56 //!
57 //! static NOTIFS: Registry = Registry::new(KINDS);
58 //! assert!(NOTIFS.check().is_ok());
59 //! ```
60
61 pub mod config;
62 pub mod deliver;
63 #[cfg(feature = "tauri")]
64 pub mod notify;
65 #[cfg(feature = "describe")]
66 pub mod pane;
67
68 pub use config::{Generated, Reach, Settings, Value};
69 pub use deliver::{Deliver, Occurrence, Outbox, Sweep};
70 #[cfg(feature = "tauri")]
71 pub use notify::Notifier;
72
73 /// Whether a kind fires for someone who has never touched its settings.
74 ///
75 /// The framework default is [`Off`](Self::Off): a notification nobody asked
76 /// for is an interruption nobody asked for, and onboarding is what points at
77 /// the ones that ship quiet.
78 ///
79 /// A kind that already fires in a shipped app declares [`On`](Self::On), so
80 /// adopting the framework does not silently stop a notification somebody
81 /// depends on today. That is a migration fact rather than a preference, and it
82 /// is one line per kind either way.
83 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84 pub enum Posture {
85 /// Fires unless the reader turns it off.
86 On,
87 /// Silent unless the reader turns it on.
88 Off,
89 }
90
91 /// What a restart owes the occurrences that came due while the app was shut.
92 ///
93 /// Measured on goingson's watcher, which answers this twice and differently.
94 /// A snooze that expired overnight is still expired and the reader still wants
95 /// it back, so it fires: [`Fire`](Self::Fire). An event reminder for a meeting
96 /// that started an hour ago is an interruption about something already missed,
97 /// and the shipped watcher bootstraps those away on its first tick:
98 /// [`Skip`](Self::Skip).
99 ///
100 /// It is on the declaration rather than on the host because it is a fact about
101 /// what the kind *means* -- whether the occurrence keeps its worth once it is
102 /// late -- and every renderer would otherwise answer it separately and
103 /// differently. [`Fire`](Self::Fire) is the default because it is the answer
104 /// that loses nothing; a kind that would interrupt about a moment that has
105 /// passed says so.
106 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
107 pub enum CatchUp {
108 /// Deliver it late. It kept its worth.
109 #[default]
110 Fire,
111 /// Note it as seen and say nothing. The moment has passed.
112 Skip,
113 }
114
115 /// What a knob holds, and what it holds before anyone touches it.
116 ///
117 /// The type and the default are one value rather than two members, so a knob
118 /// cannot carry a default of the wrong type. That is the same reason
119 /// [`Kind::ships`] is a [`Posture`] and not a `bool` beside a comment.
120 ///
121 /// # What is deliberately not here
122 ///
123 /// A list-valued knob. goingson's event reminders take several lead times, and
124 /// they are a property of the *event* (`event.reminder_offsets_seconds`) rather
125 /// than of the kind, so no measured site wants one here. Adding a variant for a
126 /// shape nothing has asked for is how a vocabulary drifts, and it is one
127 /// variant whenever something does.
128 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129 pub enum Setting {
130 /// On or off, and which it starts as.
131 Toggle(bool),
132 /// A span, in seconds. A lead time.
133 Seconds(i64),
134 /// A plain number. A threshold, a count.
135 Count(i64),
136 /// One of a fixed set.
137 ///
138 /// `default` has to be one of `options`; [`Registry::check`] is what says
139 /// so, because a `const` cannot.
140 Choice {
141 /// What may be chosen, in the order they are offered.
142 options: &'static [&'static str],
143 /// What is chosen before anyone chooses.
144 default: &'static str,
145 },
146 }
147
148 /// One granular knob belonging to one kind.
149 ///
150 /// The half of "automatically generating granular configuration" that carries
151 /// the weight: a kind with no knobs generates a single on/off, and a kind with
152 /// knobs generates a section.
153 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
154 pub struct Knob {
155 /// The config key stem, under the kind's own.
156 ///
157 /// Same shape rule as [`Kind::id`]: unprefixed, because the kind it belongs
158 /// to is already the prefix.
159 pub id: &'static str,
160 /// What the settings pane calls it.
161 pub label: &'static str,
162 /// What it holds, and what it holds by default.
163 pub value: Setting,
164 /// Whether this knob's generated key travels between a user's devices.
165 ///
166 /// [`Reach::Synced`] by default, because a notification preference is a
167 /// preference and answering it twice on two machines is the thing config
168 /// sync exists to stop. See [`Knob::on_this_device`] for the exception.
169 pub reach: Reach,
170 }
171
172 impl Knob {
173 /// A knob of the given kind of value, synced.
174 #[must_use]
175 pub const fn new(id: &'static str, label: &'static str, value: Setting) -> Self {
176 Self {
177 id,
178 label,
179 value,
180 reach: Reach::Synced,
181 }
182 }
183
184 /// This knob's answer is about one machine, chaining.
185 ///
186 /// The narrow case: a knob whose answer would be wrong on the other device
187 /// rather than merely unset there. A quiet-hours knob is a preference and
188 /// syncs; "which sound this laptop plays" is about this laptop.
189 #[must_use]
190 pub const fn on_this_device(mut self) -> Self {
191 self.reach = Reach::Local;
192 self
193 }
194 }
195
196 /// One kind of notification, declared once.
197 ///
198 /// A kind is not an instance. "Snoozed items resurface" is a kind; the
199 /// notification about the one task that came back at 4pm is not, and nothing
200 /// here describes it. What this carries is everything a settings pane, an
201 /// onboarding pointer and a config file need, which is exactly the set that
202 /// three renderers would otherwise each answer differently.
203 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
204 pub struct Kind {
205 /// The stable name, and the config key stem.
206 ///
207 /// Unprefixed by the app: `snooze-expiry`, never `goingson-snooze-expiry`.
208 /// The registry belongs to one app already, and a key that repeats the app
209 /// name is a key that has to be rewritten when the app is renamed.
210 /// [`Registry::check`] enforces the shape.
211 pub id: &'static str,
212 /// What the settings pane calls it.
213 pub title: &'static str,
214 /// One line saying what it is for.
215 ///
216 /// What the pane renders under the title, and what onboarding points at.
217 /// One line because two is a paragraph nobody reads in a list of twenty.
218 pub summary: &'static str,
219 /// What it groups under in the generated pane.
220 ///
221 /// A plain string rather than an enum: the categories are the app's, and a
222 /// closed set here would mean this crate deciding what kinds of
223 /// notification exist.
224 pub category: &'static str,
225 /// Whether it fires for someone who has never touched it.
226 pub ships: Posture,
227 /// Whether this kind's generated `enabled` key travels between a user's
228 /// devices. [`Reach::Synced`] by default, as [`Knob::reach`] is.
229 pub reach: Reach,
230 /// What a restart owes the occurrences that came due while the app was shut.
231 pub restart: CatchUp,
232 /// The knobs this kind has, in the order they are offered.
233 pub options: &'static [Knob],
234 }
235
236 impl Kind {
237 /// A kind that ships off and has no knobs.
238 ///
239 /// Off because that is the framework default and the thing a declaration
240 /// should have to say out loud is the interruption, not the silence.
241 #[must_use]
242 pub const fn new(
243 id: &'static str,
244 title: &'static str,
245 summary: &'static str,
246 category: &'static str,
247 ) -> Self {
248 Self {
249 id,
250 title,
251 summary,
252 category,
253 ships: Posture::Off,
254 reach: Reach::Synced,
255 restart: CatchUp::Fire,
256 options: &[],
257 }
258 }
259
260 /// This one fires unless it is turned off, chaining.
261 #[must_use]
262 pub const fn shipping_on(mut self) -> Self {
263 self.ships = Posture::On;
264 self
265 }
266
267 /// The knobs this kind offers, chaining.
268 #[must_use]
269 pub const fn with(mut self, options: &'static [Knob]) -> Self {
270 self.options = options;
271 self
272 }
273
274 /// Whether this kind is on is a fact about one machine, chaining.
275 ///
276 /// Rare, and it should be: turning a kind off on the laptop and being
277 /// interrupted by it on the tablet is the state the default avoids.
278 #[must_use]
279 pub const fn on_this_device(mut self) -> Self {
280 self.reach = Reach::Local;
281 self
282 }
283
284 /// What was due while the app was shut has passed, chaining.
285 ///
286 /// See [`CatchUp::Skip`], which this selects.
287 #[must_use]
288 pub const fn quiet_after_restart(mut self) -> Self {
289 self.restart = CatchUp::Skip;
290 self
291 }
292
293 /// The knob under this kind with the given id.
294 #[must_use]
295 pub fn knob(&self, id: &str) -> Option<&Knob> {
296 self.options.iter().find(|knob| knob.id == id)
297 }
298 }
299
300 /// An app's whole set of notification kinds.
301 ///
302 /// Declared once and handed to the host. One per app: a second registry is two
303 /// answers to "what can this app notify me about", and the settings pane can
304 /// only render one of them.
305 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
306 pub struct Registry {
307 kinds: &'static [Kind],
308 }
309
310 impl Registry {
311 /// The registry holding these kinds.
312 #[must_use]
313 pub const fn new(kinds: &'static [Kind]) -> Self {
314 Self { kinds }
315 }
316
317 /// Every kind, in declaration order.
318 #[must_use]
319 pub const fn kinds(&self) -> &'static [Kind] {
320 self.kinds
321 }
322
323 /// The kind with the given id.
324 #[must_use]
325 pub fn kind(&self, id: &str) -> Option<&Kind> {
326 self.kinds.iter().find(|kind| kind.id == id)
327 }
328
329 /// Every category present, in the order its first kind declared it.
330 ///
331 /// Declaration order rather than alphabetical, because the app grouped
332 /// these deliberately and sorting would replace its judgment with the
333 /// alphabet's.
334 #[must_use]
335 pub fn categories(&self) -> Vec<&'static str> {
336 let mut seen: Vec<&'static str> = Vec::new();
337 for kind in self.kinds {
338 if !seen.contains(&kind.category) {
339 seen.push(kind.category);
340 }
341 }
342 seen
343 }
344
345 /// What is wrong with this registry, if anything is.
346 ///
347 /// Everything a `const` cannot check. Call it from a test rather than at
348 /// startup: a registry is a compile-time constant, so a failure here is a
349 /// failure of the source and not of the run, and a check that only fires in
350 /// production is a check that ships broken.
351 ///
352 /// # Errors
353 ///
354 /// The first [`Fault`] found, in the order the kinds were declared.
355 pub fn check(&self) -> Result<(), Fault> {
356 let mut seen: Vec<&'static str> = Vec::new();
357 for kind in self.kinds {
358 if !is_key(kind.id) {
359 return Err(Fault::BadId { id: kind.id });
360 }
361 if seen.contains(&kind.id) {
362 return Err(Fault::TwoKinds { id: kind.id });
363 }
364 seen.push(kind.id);
365
366 let mut knobs: Vec<&'static str> = Vec::new();
367 for knob in kind.options {
368 if !is_key(knob.id) {
369 return Err(Fault::BadId { id: knob.id });
370 }
371 if knobs.contains(&knob.id) {
372 return Err(Fault::TwoKnobs {
373 kind: kind.id,
374 id: knob.id,
375 });
376 }
377 knobs.push(knob.id);
378
379 if let Setting::Choice { options, default } = knob.value
380 && !options.contains(&default)
381 {
382 return Err(Fault::DefaultNotOffered {
383 kind: kind.id,
384 id: knob.id,
385 });
386 }
387 }
388 }
389 Ok(())
390 }
391 }
392
393 /// Whether a string can be a config key stem.
394 ///
395 /// Lowercase, digits and hyphens. No dots, because a dot is what separates a
396 /// kind's key from a knob's and an id containing one would make the generated
397 /// key ambiguous about which is which.
398 fn is_key(id: &str) -> bool {
399 !id.is_empty()
400 && id
401 .chars()
402 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
403 }
404
405 /// Something wrong with a registry that a `const` could not catch.
406 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
407 pub enum Fault {
408 /// An id that cannot be a config key stem.
409 BadId {
410 /// The id as declared.
411 id: &'static str,
412 },
413 /// Two kinds under one id, so their settings would share a key.
414 TwoKinds {
415 /// The repeated id.
416 id: &'static str,
417 },
418 /// Two knobs under one id within a kind.
419 TwoKnobs {
420 /// The kind holding both.
421 kind: &'static str,
422 /// The repeated id.
423 id: &'static str,
424 },
425 /// A [`Setting::Choice`] whose default is not one of its options.
426 DefaultNotOffered {
427 /// The kind holding the knob.
428 kind: &'static str,
429 /// The knob.
430 id: &'static str,
431 },
432 }
433
434 impl std::fmt::Display for Fault {
435 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436 match self {
437 Self::BadId { id } => {
438 write!(
439 f,
440 "`{id}` cannot be a config key: lowercase, digits and hyphens only"
441 )
442 }
443 Self::TwoKinds { id } => write!(f, "two kinds are declared as `{id}`"),
444 Self::TwoKnobs { kind, id } => {
445 write!(f, "`{kind}` declares two knobs as `{id}`")
446 }
447 Self::DefaultNotOffered { kind, id } => {
448 write!(f, "`{kind}.{id}` defaults to something it does not offer")
449 }
450 }
451 }
452 }
453
454 impl std::error::Error for Fault {}
455
456 #[cfg(test)]
457 mod tests {
458 use super::*;
459
460 /// A registry of two kinds, which is the smallest one that can be wrong in
461 /// an interesting way: one kind cannot collide, and one knob cannot either.
462 static KINDS: &[Kind] = &[
463 Kind::new(
464 "snooze-expiry",
465 "Snoozed items resurface",
466 "When something you snoozed comes back.",
467 "Reminders",
468 )
469 .shipping_on(),
470 Kind::new(
471 "digest",
472 "Daily digest",
473 "One summary of the day, at a time you pick.",
474 "Summaries",
475 )
476 .with(&[
477 Knob::new("at", "When", Setting::Seconds(9 * 3600)),
478 Knob::new(
479 "include",
480 "What to include",
481 Setting::Choice {
482 options: &["everything", "overdue only"],
483 default: "overdue only",
484 },
485 ),
486 ]),
487 ];
488
489 static NOTIFS: Registry = Registry::new(KINDS);
490
491 #[test]
492 fn a_registry_is_a_constant_and_a_valid_one() {
493 // The whole point of const-friendly data: this is a `static`, not
494 // something a startup path built. If that stops being true the
495 // declaration can differ between two places that build it.
496 assert!(NOTIFS.check().is_ok());
497 assert_eq!(NOTIFS.kinds().len(), 2);
498 }
499
500 #[test]
501 fn a_kind_ships_off_unless_it_says_otherwise() {
502 // Framework default, decided 2026-08-17. A declaration should have to
503 // say the interruption out loud, not the silence.
504 assert_eq!(NOTIFS.kind("digest").unwrap().ships, Posture::Off);
505 assert_eq!(NOTIFS.kind("snooze-expiry").unwrap().ships, Posture::On);
506 }
507
508 #[test]
509 fn a_knob_carries_its_type_and_its_default_as_one_value() {
510 let digest = NOTIFS.kind("digest").unwrap();
511 assert_eq!(digest.knob("at").unwrap().value, Setting::Seconds(9 * 3600));
512 assert_eq!(digest.options.len(), 2);
513 // A kind with no knobs generates a single on/off rather than a section.
514 assert!(NOTIFS.kind("snooze-expiry").unwrap().options.is_empty());
515 }
516
517 #[test]
518 fn categories_keep_the_order_the_app_declared_them_in() {
519 // Not sorted. The app grouped these deliberately and the alphabet has
520 // no opinion worth substituting for that.
521 assert_eq!(NOTIFS.categories(), vec!["Reminders", "Summaries"]);
522 }
523
524 #[test]
525 fn an_unknown_id_is_absent_rather_than_a_panic() {
526 assert!(NOTIFS.kind("nope").is_none());
527 assert!(NOTIFS.kind("digest").unwrap().knob("nope").is_none());
528 }
529
530 #[test]
531 fn two_kinds_under_one_id_would_share_a_config_key() {
532 static CLASH: &[Kind] = &[
533 Kind::new("digest", "One", "First.", "A"),
534 Kind::new("digest", "Two", "Second.", "B"),
535 ];
536 assert_eq!(
537 Registry::new(CLASH).check(),
538 Err(Fault::TwoKinds { id: "digest" })
539 );
540 }
541
542 #[test]
543 fn two_knobs_under_one_id_would_too() {
544 static CLASH: &[Kind] = &[Kind::new("digest", "Digest", "Daily.", "A").with(&[
545 Knob::new("at", "When", Setting::Seconds(0)),
546 Knob::new("at", "Also when", Setting::Count(0)),
547 ])];
548 assert_eq!(
549 Registry::new(CLASH).check(),
550 Err(Fault::TwoKnobs {
551 kind: "digest",
552 id: "at"
553 })
554 );
555 }
556
557 #[test]
558 fn an_id_that_cannot_be_a_config_key_is_refused() {
559 // The family convention: `theme`, not `goingson-theme`, and never a
560 // dot -- a dot is what separates a kind's key from a knob's.
561 for bad in ["Digest", "daily.digest", "daily digest", ""] {
562 let kinds: &'static [Kind] = Box::leak(Box::new([Kind::new(bad, "T", "S", "C")]));
563 assert_eq!(
564 Registry::new(kinds).check(),
565 Err(Fault::BadId { id: bad }),
566 "{bad:?} should be refused"
567 );
568 }
569 // A hyphenated stem is the shape the shipped kinds already use.
570 static GOOD: &[Kind] = &[Kind::new("snooze-expiry", "T", "S", "C")];
571 assert!(Registry::new(GOOD).check().is_ok());
572 }
573
574 #[test]
575 fn a_choice_cannot_default_to_something_it_does_not_offer() {
576 static BAD: &[Kind] = &[
577 Kind::new("digest", "Digest", "Daily.", "A").with(&[Knob::new(
578 "include",
579 "What",
580 Setting::Choice {
581 options: &["everything"],
582 default: "overdue only",
583 },
584 )]),
585 ];
586 assert_eq!(
587 Registry::new(BAD).check(),
588 Err(Fault::DefaultNotOffered {
589 kind: "digest",
590 id: "include"
591 })
592 );
593 }
594
595 #[test]
596 fn a_fault_says_which_declaration_is_wrong() {
597 // The message is read by whoever wrote the registry, so it names the
598 // ids rather than the position in a slice.
599 assert!(
600 Fault::TwoKnobs {
601 kind: "digest",
602 id: "at"
603 }
604 .to_string()
605 .contains("`digest` declares two knobs as `at`")
606 );
607 }
608 }
609