1use std::time::Duration;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum State {
17 Active,
19 Pending,
24 Terminated,
26}
27
28impl State {
29 #[must_use]
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::Active => "active",
34 Self::Pending => "pending",
35 Self::Terminated => "terminated",
36 }
37 }
38
39 #[must_use]
41 pub fn parse(token: &str) -> Option<Self> {
42 [Self::Active, Self::Pending, Self::Terminated]
43 .into_iter()
44 .find(|candidate| token.trim().eq_ignore_ascii_case(candidate.as_str()))
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Reason {
55 Deactivated,
57 Probation,
59 Rejected,
61 Timeout,
63 GiveUp,
65 NoResource,
67 Invariant,
69 BadFilter,
71}
72
73impl Reason {
74 #[must_use]
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::Deactivated => "deactivated",
79 Self::Probation => "probation",
80 Self::Rejected => "rejected",
81 Self::Timeout => "timeout",
82 Self::GiveUp => "giveup",
83 Self::NoResource => "noresource",
84 Self::Invariant => "invariant",
85 Self::BadFilter => "badfilter",
86 }
87 }
88
89 #[must_use]
91 pub fn parse(token: &str) -> Option<Self> {
92 [
93 Self::Deactivated,
94 Self::Probation,
95 Self::Rejected,
96 Self::Timeout,
97 Self::GiveUp,
98 Self::NoResource,
99 Self::Invariant,
100 Self::BadFilter,
101 ]
102 .into_iter()
103 .find(|candidate| token.trim().eq_ignore_ascii_case(candidate.as_str()))
104 }
105
106 #[must_use]
111 pub fn should_resubscribe(self) -> bool {
112 matches!(self, Self::Deactivated | Self::Probation | Self::Timeout)
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Subscription {
119 pub state: State,
121 pub expires: Option<Duration>,
125 pub reason: Option<Reason>,
127 pub retry_after: Option<Duration>,
129}
130
131impl Subscription {
132 #[must_use]
134 pub fn active(expires: Duration) -> Self {
135 Self {
136 state: State::Active,
137 expires: Some(expires),
138 reason: None,
139 retry_after: None,
140 }
141 }
142
143 #[must_use]
145 pub fn terminated(reason: Reason) -> Self {
146 Self {
147 state: State::Terminated,
148 expires: None,
149 reason: Some(reason),
150 retry_after: None,
151 }
152 }
153
154 #[must_use]
156 pub fn is_terminated(self_: &Self) -> bool {
157 self_.state == State::Terminated
158 }
159
160 #[must_use]
162 pub fn parse(value: &[u8]) -> Option<Self> {
163 let text = String::from_utf8_lossy(value);
164 let mut parts = text.split(';');
165 let state = State::parse(parts.next()?)?;
166 let mut subscription = Self {
167 state,
168 expires: None,
169 reason: None,
170 retry_after: None,
171 };
172 for parameter in parts {
173 let Some((name, value)) = parameter.split_once('=') else {
174 continue;
175 };
176 let (name, value) = (name.trim(), value.trim().trim_matches('"'));
177 if name.eq_ignore_ascii_case("expires") {
178 subscription.expires = value.parse().ok().map(Duration::from_secs);
179 } else if name.eq_ignore_ascii_case("reason") {
180 subscription.reason = Reason::parse(value);
181 } else if name.eq_ignore_ascii_case("retry-after") {
182 subscription.retry_after = value.parse().ok().map(Duration::from_secs);
183 }
184 }
185 Some(subscription)
186 }
187
188 #[must_use]
190 pub fn to_value(&self) -> String {
191 use std::fmt::Write as _;
192 let mut out = self.state.as_str().to_owned();
193 if self.state != State::Terminated
194 && let Some(expires) = self.expires
195 {
196 let _ = write!(out, ";expires={}", expires.as_secs());
197 }
198 if let Some(reason) = self.reason {
199 let _ = write!(out, ";reason={}", reason.as_str());
200 }
201 if let Some(retry) = self.retry_after {
202 let _ = write!(out, ";retry-after={}", retry.as_secs());
203 }
204 out
205 }
206}
207
208#[must_use]
216pub fn granted_expiry(requested: Duration, policy_maximum: Duration) -> Duration {
217 requested.min(policy_maximum)
218}
219
220#[must_use]
226pub fn is_unsubscribe(requested: Duration) -> bool {
227 requested.is_zero()
228}
229
230#[derive(Debug, Clone, Default)]
236pub struct Packages {
237 names: Vec<String>,
238}
239
240impl Packages {
241 #[must_use]
243 pub fn new() -> Self {
244 Self::default()
245 }
246
247 #[must_use]
249 pub fn with(mut self, name: impl Into<String>) -> Self {
250 let name = name.into();
251 if !self
252 .names
253 .iter()
254 .any(|held| held.eq_ignore_ascii_case(&name))
255 {
256 self.names.push(name);
257 }
258 self
259 }
260
261 #[must_use]
267 pub fn serves(&self, event: &str) -> bool {
268 let package = event.split(';').next().unwrap_or_default().trim();
269 let base = package.split('.').next().unwrap_or_default();
270 self.names
271 .iter()
272 .any(|held| held.eq_ignore_ascii_case(base) || held.eq_ignore_ascii_case(package))
273 }
274
275 #[must_use]
277 pub fn names(&self) -> &[String] {
278 &self.names
279 }
280
281 #[must_use]
283 pub fn allow_events(&self) -> String {
284 self.names.join(", ")
285 }
286}
287
288pub const BAD_EVENT: u16 = 489;
294
295#[cfg(test)]
296#[allow(
297 clippy::unwrap_used,
298 clippy::expect_used,
299 clippy::panic,
300 clippy::indexing_slicing
301)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn a_subscription_state_round_trips() {
307 let active = Subscription::active(Duration::from_secs(3600));
308 assert_eq!(active.to_value(), "active;expires=3600");
309 assert_eq!(Subscription::parse(b"active;expires=3600"), Some(active));
310
311 let ended = Subscription::terminated(Reason::Timeout);
312 assert_eq!(ended.to_value(), "terminated;reason=timeout");
313 assert_eq!(
314 Subscription::parse(b"terminated;reason=timeout"),
315 Some(ended)
316 );
317 }
318
319 #[test]
322 fn a_terminated_state_carries_no_expiry() {
323 let mut ended = Subscription::terminated(Reason::NoResource);
324 ended.expires = Some(Duration::from_secs(60));
325 assert_eq!(ended.to_value(), "terminated;reason=noresource");
326 }
327
328 #[test]
329 fn the_three_states_are_told_apart() {
330 assert_eq!(State::parse("active"), Some(State::Active));
331 assert_eq!(State::parse("PENDING"), Some(State::Pending));
332 assert_eq!(State::parse(" terminated "), Some(State::Terminated));
333 assert_eq!(State::parse("finished"), None);
334 assert_ne!(State::Pending, State::Active);
337 }
338
339 #[test]
340 fn every_reason_the_rfc_defines_round_trips() {
341 for reason in [
342 Reason::Deactivated,
343 Reason::Probation,
344 Reason::Rejected,
345 Reason::Timeout,
346 Reason::GiveUp,
347 Reason::NoResource,
348 Reason::Invariant,
349 Reason::BadFilter,
350 ] {
351 assert_eq!(Reason::parse(reason.as_str()), Some(reason));
352 }
353 assert_eq!(Reason::parse("because"), None);
354 }
355
356 #[test]
359 fn a_refusal_and_a_timeout_lead_to_different_behaviour() {
360 assert!(Reason::Timeout.should_resubscribe());
361 assert!(Reason::Deactivated.should_resubscribe());
362 assert!(Reason::Probation.should_resubscribe());
363 assert!(!Reason::Rejected.should_resubscribe());
364 assert!(!Reason::NoResource.should_resubscribe());
365 }
366
367 #[test]
368 fn a_retry_after_survives_the_round_trip() {
369 let parsed =
370 Subscription::parse(b"terminated;reason=probation;retry-after=1800").expect("parses");
371 assert_eq!(parsed.reason, Some(Reason::Probation));
372 assert_eq!(parsed.retry_after, Some(Duration::from_secs(1800)));
373 assert!(parsed.reason.expect("a reason").should_resubscribe());
374 }
375
376 #[test]
378 fn a_notifier_may_shorten_an_expiry_and_never_lengthen_it() {
379 let hour = Duration::from_secs(3600);
380 let day = Duration::from_secs(86400);
381 assert_eq!(granted_expiry(day, hour), hour, "shortened to the policy");
382 assert_eq!(
383 granted_expiry(hour, day),
384 hour,
385 "a generous policy does not lengthen what was asked for"
386 );
387 }
388
389 #[test]
392 fn an_expiry_of_zero_is_an_unsubscribe() {
393 assert!(is_unsubscribe(Duration::ZERO));
394 assert!(!is_unsubscribe(Duration::from_secs(1)));
395 assert_eq!(
396 granted_expiry(Duration::ZERO, Duration::from_secs(3600)),
397 Duration::ZERO,
398 "a generous policy must not turn an unsubscribe into a subscription"
399 );
400 }
401
402 #[test]
403 fn a_package_is_served_by_name_whatever_its_parameters() {
404 let packages = Packages::new().with("dialog").with("presence");
405 assert!(packages.serves("dialog"));
406 assert!(packages.serves("DIALOG"));
407 assert!(packages.serves("dialog;call-id=x"));
408 assert!(packages.serves("presence"));
409 assert!(!packages.serves("refer"));
410 assert!(!packages.serves(""));
411 }
412
413 #[test]
415 fn a_template_is_recognised_as_its_package() {
416 let packages = Packages::new().with("dialog");
417 assert!(packages.serves("dialog.winfo"));
418 }
419
420 #[test]
421 fn allow_events_lists_what_is_served_and_a_package_is_not_listed_twice() {
422 let packages = Packages::new()
423 .with("dialog")
424 .with("presence")
425 .with("DIALOG");
426 assert_eq!(packages.allow_events(), "dialog, presence");
427 assert_eq!(packages.names().len(), 2);
428 }
429
430 #[test]
433 fn an_unserved_package_has_its_own_status() {
434 assert_eq!(BAD_EVENT, 489);
435 }
436}