systemprompt_models/notifications/
declarations.rs1use std::collections::HashSet;
8
9use chrono::{NaiveTime, Weekday};
10use serde::{Deserialize, Serialize};
11use systemprompt_identifiers::{NotificationRuleId, NotificationStreamId};
12
13use super::attention::Attention;
14use super::subject::{AudienceSubject, SubjectRef};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct StreamDecl {
20 pub id: NotificationStreamId,
21 pub name: String,
22 #[serde(default)]
23 pub description: Option<String>,
24 pub audience: Vec<AudienceSubject>,
25 pub publishers: Vec<AudienceSubject>,
26 #[serde(default)]
27 pub attention: StreamAttention,
28 #[serde(default)]
29 pub retention_days: Option<u32>,
30 #[serde(default)]
31 pub coalesce_window_secs: Option<u32>,
32}
33
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields, default)]
37pub struct StreamAttention {
38 pub default: Attention,
39 pub floor: Option<Attention>,
40 pub ceiling: Option<Attention>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct SubscriptionDecl {
47 pub subject: AudienceSubject,
48 pub target: SubscriptionTarget,
49 #[serde(default)]
50 pub attention_ceiling: Option<Attention>,
51 #[serde(default)]
52 pub quiet_hours: Option<QuietHours>,
53 #[serde(default)]
54 pub digest: Option<DigestWindow>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(try_from = "TargetMap", into = "TargetMap")]
63pub enum SubscriptionTarget {
64 Stream(NotificationStreamId),
65 Resource(SubjectRef),
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70struct TargetMap {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 stream: Option<NotificationStreamId>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 resource: Option<SubjectRef>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
78#[error("a subscription target names exactly one of `stream` or `resource`")]
79pub struct SubscriptionTargetError;
80
81impl TryFrom<TargetMap> for SubscriptionTarget {
82 type Error = SubscriptionTargetError;
83
84 fn try_from(map: TargetMap) -> Result<Self, Self::Error> {
85 match (map.stream, map.resource) {
86 (Some(stream), None) => Ok(Self::Stream(stream)),
87 (None, Some(resource)) => Ok(Self::Resource(resource)),
88 _ => Err(SubscriptionTargetError),
89 }
90 }
91}
92
93impl From<SubscriptionTarget> for TargetMap {
94 fn from(target: SubscriptionTarget) -> Self {
95 match target {
96 SubscriptionTarget::Stream(stream) => Self {
97 stream: Some(stream),
98 resource: None,
99 },
100 SubscriptionTarget::Resource(resource) => Self {
101 stream: None,
102 resource: Some(resource),
103 },
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct QuietHours {
112 pub start: NaiveTime,
113 pub end: NaiveTime,
114 pub tz: String,
115 #[serde(default)]
116 pub weekdays: Option<Vec<Weekday>>,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct DigestWindow {
123 pub every_secs: u32,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct RuleDecl {
130 pub id: NotificationRuleId,
131 pub name: String,
132 #[serde(default)]
133 pub priority: i32,
134 #[serde(default)]
135 pub r#match: RuleMatch,
136 #[serde(default)]
137 pub action: RuleAction,
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields, default)]
143pub struct RuleMatch {
144 pub kinds: Vec<String>,
145 pub streams: Vec<NotificationStreamId>,
146 pub resources: Vec<SubjectRef>,
147 pub actors: Vec<AudienceSubject>,
148 pub actor_is_agent: Option<bool>,
149 pub recipients: Vec<AudienceSubject>,
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(deny_unknown_fields, default)]
155pub struct RuleAction {
156 pub set: Option<Attention>,
157 pub cap: Option<Attention>,
158 pub floor: Option<Attention>,
159 pub mute: bool,
160 pub coalesce_window_secs: Option<u32>,
161 pub digest: Option<DigestWindow>,
162 pub quiet_hours: Option<QuietHours>,
163}
164
165#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields, default)]
168pub struct NotificationsDoc {
169 pub streams: Vec<StreamDecl>,
170 pub rules: Vec<RuleDecl>,
171 pub subscriptions: Vec<SubscriptionDecl>,
172}
173
174impl NotificationsDoc {
175 pub fn merge(&mut self, other: Self) {
176 self.streams.extend(other.streams);
177 self.rules.extend(other.rules);
178 self.subscriptions.extend(other.subscriptions);
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
184pub enum DeclError {
185 #[error("stream {0} is declared more than once")]
186 DuplicateStream(NotificationStreamId),
187 #[error("rule {0} is declared more than once")]
188 DuplicateRule(NotificationRuleId),
189 #[error("stream {0} has an attention floor above its ceiling")]
190 StreamFloorAboveCeiling(NotificationStreamId),
191 #[error("rule {0} has an attention floor above its cap")]
192 RuleFloorAboveCap(NotificationRuleId),
193 #[error("stream {0} declares no audience")]
194 EmptyAudience(NotificationStreamId),
195 #[error("stream {0} declares no publishers")]
196 EmptyPublishers(NotificationStreamId),
197 #[error("rule {rule} references undeclared stream {stream}")]
198 UnknownRuleStream {
199 rule: NotificationRuleId,
200 stream: NotificationStreamId,
201 },
202 #[error("subscription references undeclared stream {0}")]
203 UnknownSubscriptionStream(NotificationStreamId),
204}
205
206pub fn validate(doc: &NotificationsDoc) -> Result<(), DeclError> {
207 let mut streams = HashSet::new();
208 for stream in &doc.streams {
209 if !streams.insert(&stream.id) {
210 return Err(DeclError::DuplicateStream(stream.id.clone()));
211 }
212 if let (Some(floor), Some(ceiling)) = (stream.attention.floor, stream.attention.ceiling)
213 && floor > ceiling
214 {
215 return Err(DeclError::StreamFloorAboveCeiling(stream.id.clone()));
216 }
217 if stream.audience.is_empty() {
218 return Err(DeclError::EmptyAudience(stream.id.clone()));
219 }
220 if stream.publishers.is_empty() {
221 return Err(DeclError::EmptyPublishers(stream.id.clone()));
222 }
223 }
224 let mut rules = HashSet::new();
225 for rule in &doc.rules {
226 if !rules.insert(&rule.id) {
227 return Err(DeclError::DuplicateRule(rule.id.clone()));
228 }
229 if let (Some(floor), Some(cap)) = (rule.action.floor, rule.action.cap)
230 && floor > cap
231 {
232 return Err(DeclError::RuleFloorAboveCap(rule.id.clone()));
233 }
234 if let Some(stream) = rule.r#match.streams.iter().find(|s| !streams.contains(s)) {
235 return Err(DeclError::UnknownRuleStream {
236 rule: rule.id.clone(),
237 stream: stream.clone(),
238 });
239 }
240 }
241 for subscription in &doc.subscriptions {
242 if let SubscriptionTarget::Stream(stream) = &subscription.target
243 && !streams.contains(stream)
244 {
245 return Err(DeclError::UnknownSubscriptionStream(stream.clone()));
246 }
247 }
248 Ok(())
249}