use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Active,
Pending,
Terminated,
}
impl State {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Pending => "pending",
Self::Terminated => "terminated",
}
}
#[must_use]
pub fn parse(token: &str) -> Option<Self> {
[Self::Active, Self::Pending, Self::Terminated]
.into_iter()
.find(|candidate| token.trim().eq_ignore_ascii_case(candidate.as_str()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
Deactivated,
Probation,
Rejected,
Timeout,
GiveUp,
NoResource,
Invariant,
BadFilter,
}
impl Reason {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Deactivated => "deactivated",
Self::Probation => "probation",
Self::Rejected => "rejected",
Self::Timeout => "timeout",
Self::GiveUp => "giveup",
Self::NoResource => "noresource",
Self::Invariant => "invariant",
Self::BadFilter => "badfilter",
}
}
#[must_use]
pub fn parse(token: &str) -> Option<Self> {
[
Self::Deactivated,
Self::Probation,
Self::Rejected,
Self::Timeout,
Self::GiveUp,
Self::NoResource,
Self::Invariant,
Self::BadFilter,
]
.into_iter()
.find(|candidate| token.trim().eq_ignore_ascii_case(candidate.as_str()))
}
#[must_use]
pub fn should_resubscribe(self) -> bool {
matches!(self, Self::Deactivated | Self::Probation | Self::Timeout)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subscription {
pub state: State,
pub expires: Option<Duration>,
pub reason: Option<Reason>,
pub retry_after: Option<Duration>,
}
impl Subscription {
#[must_use]
pub fn active(expires: Duration) -> Self {
Self {
state: State::Active,
expires: Some(expires),
reason: None,
retry_after: None,
}
}
#[must_use]
pub fn terminated(reason: Reason) -> Self {
Self {
state: State::Terminated,
expires: None,
reason: Some(reason),
retry_after: None,
}
}
#[must_use]
pub fn is_terminated(self_: &Self) -> bool {
self_.state == State::Terminated
}
#[must_use]
pub fn parse(value: &[u8]) -> Option<Self> {
let text = String::from_utf8_lossy(value);
let mut parts = text.split(';');
let state = State::parse(parts.next()?)?;
let mut subscription = Self {
state,
expires: None,
reason: None,
retry_after: None,
};
for parameter in parts {
let Some((name, value)) = parameter.split_once('=') else {
continue;
};
let (name, value) = (name.trim(), value.trim().trim_matches('"'));
if name.eq_ignore_ascii_case("expires") {
subscription.expires = value.parse().ok().map(Duration::from_secs);
} else if name.eq_ignore_ascii_case("reason") {
subscription.reason = Reason::parse(value);
} else if name.eq_ignore_ascii_case("retry-after") {
subscription.retry_after = value.parse().ok().map(Duration::from_secs);
}
}
Some(subscription)
}
#[must_use]
pub fn to_value(&self) -> String {
use std::fmt::Write as _;
let mut out = self.state.as_str().to_owned();
if self.state != State::Terminated
&& let Some(expires) = self.expires
{
let _ = write!(out, ";expires={}", expires.as_secs());
}
if let Some(reason) = self.reason {
let _ = write!(out, ";reason={}", reason.as_str());
}
if let Some(retry) = self.retry_after {
let _ = write!(out, ";retry-after={}", retry.as_secs());
}
out
}
}
#[must_use]
pub fn granted_expiry(requested: Duration, policy_maximum: Duration) -> Duration {
requested.min(policy_maximum)
}
#[must_use]
pub fn is_unsubscribe(requested: Duration) -> bool {
requested.is_zero()
}
#[derive(Debug, Clone, Default)]
pub struct Packages {
names: Vec<String>,
}
impl Packages {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with(mut self, name: impl Into<String>) -> Self {
let name = name.into();
if !self
.names
.iter()
.any(|held| held.eq_ignore_ascii_case(&name))
{
self.names.push(name);
}
self
}
#[must_use]
pub fn serves(&self, event: &str) -> bool {
let package = event.split(';').next().unwrap_or_default().trim();
let base = package.split('.').next().unwrap_or_default();
self.names
.iter()
.any(|held| held.eq_ignore_ascii_case(base) || held.eq_ignore_ascii_case(package))
}
#[must_use]
pub fn names(&self) -> &[String] {
&self.names
}
#[must_use]
pub fn allow_events(&self) -> String {
self.names.join(", ")
}
}
pub const BAD_EVENT: u16 = 489;
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
#[test]
fn a_subscription_state_round_trips() {
let active = Subscription::active(Duration::from_secs(3600));
assert_eq!(active.to_value(), "active;expires=3600");
assert_eq!(Subscription::parse(b"active;expires=3600"), Some(active));
let ended = Subscription::terminated(Reason::Timeout);
assert_eq!(ended.to_value(), "terminated;reason=timeout");
assert_eq!(
Subscription::parse(b"terminated;reason=timeout"),
Some(ended)
);
}
#[test]
fn a_terminated_state_carries_no_expiry() {
let mut ended = Subscription::terminated(Reason::NoResource);
ended.expires = Some(Duration::from_secs(60));
assert_eq!(ended.to_value(), "terminated;reason=noresource");
}
#[test]
fn the_three_states_are_told_apart() {
assert_eq!(State::parse("active"), Some(State::Active));
assert_eq!(State::parse("PENDING"), Some(State::Pending));
assert_eq!(State::parse(" terminated "), Some(State::Terminated));
assert_eq!(State::parse("finished"), None);
assert_ne!(State::Pending, State::Active);
}
#[test]
fn every_reason_the_rfc_defines_round_trips() {
for reason in [
Reason::Deactivated,
Reason::Probation,
Reason::Rejected,
Reason::Timeout,
Reason::GiveUp,
Reason::NoResource,
Reason::Invariant,
Reason::BadFilter,
] {
assert_eq!(Reason::parse(reason.as_str()), Some(reason));
}
assert_eq!(Reason::parse("because"), None);
}
#[test]
fn a_refusal_and_a_timeout_lead_to_different_behaviour() {
assert!(Reason::Timeout.should_resubscribe());
assert!(Reason::Deactivated.should_resubscribe());
assert!(Reason::Probation.should_resubscribe());
assert!(!Reason::Rejected.should_resubscribe());
assert!(!Reason::NoResource.should_resubscribe());
}
#[test]
fn a_retry_after_survives_the_round_trip() {
let parsed =
Subscription::parse(b"terminated;reason=probation;retry-after=1800").expect("parses");
assert_eq!(parsed.reason, Some(Reason::Probation));
assert_eq!(parsed.retry_after, Some(Duration::from_secs(1800)));
assert!(parsed.reason.expect("a reason").should_resubscribe());
}
#[test]
fn a_notifier_may_shorten_an_expiry_and_never_lengthen_it() {
let hour = Duration::from_secs(3600);
let day = Duration::from_secs(86400);
assert_eq!(granted_expiry(day, hour), hour, "shortened to the policy");
assert_eq!(
granted_expiry(hour, day),
hour,
"a generous policy does not lengthen what was asked for"
);
}
#[test]
fn an_expiry_of_zero_is_an_unsubscribe() {
assert!(is_unsubscribe(Duration::ZERO));
assert!(!is_unsubscribe(Duration::from_secs(1)));
assert_eq!(
granted_expiry(Duration::ZERO, Duration::from_secs(3600)),
Duration::ZERO,
"a generous policy must not turn an unsubscribe into a subscription"
);
}
#[test]
fn a_package_is_served_by_name_whatever_its_parameters() {
let packages = Packages::new().with("dialog").with("presence");
assert!(packages.serves("dialog"));
assert!(packages.serves("DIALOG"));
assert!(packages.serves("dialog;call-id=x"));
assert!(packages.serves("presence"));
assert!(!packages.serves("refer"));
assert!(!packages.serves(""));
}
#[test]
fn a_template_is_recognised_as_its_package() {
let packages = Packages::new().with("dialog");
assert!(packages.serves("dialog.winfo"));
}
#[test]
fn allow_events_lists_what_is_served_and_a_package_is_not_listed_twice() {
let packages = Packages::new()
.with("dialog")
.with("presence")
.with("DIALOG");
assert_eq!(packages.allow_events(), "dialog, presence");
assert_eq!(packages.names().len(), 2);
}
#[test]
fn an_unserved_package_has_its_own_status() {
assert_eq!(BAD_EVENT, 489);
}
}