Skip to main content

muster/domain/
notification.rs

1use getset::Getters;
2use nutype::nutype;
3use typed_builder::TypedBuilder;
4
5use crate::domain::value::{PaneId, ProcessName};
6
7/// A Kitty notification identifier used to replace or close a prior desktop
8/// notification. The protocol restricts identifiers to escape-safe characters.
9#[nutype(
10    validate(
11        not_empty,
12        predicate = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_alphanumeric()
13            || matches!(byte, b'_' | b'-' | b'+' | b'.'))
14    ),
15    derive(Debug, Clone, PartialEq, Eq, Hash, AsRef, Display)
16)]
17pub struct NotificationId(String);
18
19/// Opaque identity for one terminal lifetime. A reused pane starts a new scope,
20/// so its Kitty identifiers cannot affect notifications from the prior terminal.
21#[nutype(derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display))]
22pub struct NotificationScope(u64);
23
24impl NotificationScope {
25    /// Returns the next terminal-lifetime identity without exposing its storage
26    /// representation to adapters.
27    pub fn next(self) -> Self {
28        Self::new(self.into_inner().wrapping_add(1))
29    }
30}
31
32/// A user-facing notification raised from a process's terminal signals: a bell,
33/// or an OSC notification sequence (iTerm2 `9`, kitty `99`, rxvt `777`).
34#[derive(Clone, Debug, Getters, TypedBuilder)]
35#[getset(get = "pub")]
36pub struct Notification {
37    /// Pane whose terminal emitted the notification.
38    pane: PaneId,
39    /// Terminal lifetime within which Kitty identifiers are unique.
40    scope: NotificationScope,
41    /// The process that raised it, used as the notification's context.
42    source: ProcessName,
43    /// Optional summary line; delivery falls back to `source` when absent.
44    #[builder(default)]
45    title: Option<String>,
46    /// Optional notification message; title-only protocol notifications leave
47    /// this absent so desktop delivery does not duplicate the summary.
48    #[builder(default)]
49    body: Option<String>,
50    /// Kitty identifier used to update or close this notification, when present.
51    #[builder(default)]
52    identifier: Option<NotificationId>,
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn kitty_identifiers_accept_only_protocol_safe_characters() {
61        assert!(NotificationId::try_new("build_1-done+now.").is_ok());
62        assert!(NotificationId::try_new("").is_err());
63        assert!(NotificationId::try_new("unsafe/id").is_err());
64    }
65
66    #[test]
67    fn notification_scopes_advance_without_raw_integer_arithmetic() {
68        assert_eq!(NotificationScope::new(0).next(), NotificationScope::new(1));
69    }
70}