use core::fmt;
use std::sync::Arc;
use crate::domain::RequestId;
use crate::domain::session::{AbortReason, Instant, RequestOrigin, SessionId, SessionState};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionEvent {
Opened {
session: SessionId,
at: Instant,
},
Negotiated {
session: SessionId,
at: Instant,
version: String,
echoed: bool,
},
Operating {
session: SessionId,
at: Instant,
},
Registered {
session: SessionId,
at: Instant,
id: RequestId,
method: Arc<str>,
origin: RequestOrigin,
},
Completed {
session: SessionId,
at: Instant,
id: RequestId,
method: Arc<str>,
},
Aborted {
session: SessionId,
at: Instant,
id: RequestId,
method: Arc<str>,
reason: AbortReason,
},
Refused {
session: SessionId,
at: Instant,
id: Option<RequestId>,
method: Arc<str>,
state: SessionState,
},
StateChanged {
session: SessionId,
at: Instant,
state: SessionState,
},
}
impl SessionEvent {
#[must_use]
pub const fn session(&self) -> SessionId {
match self {
Self::Opened { session, .. }
| Self::Negotiated { session, .. }
| Self::Operating { session, .. }
| Self::Registered { session, .. }
| Self::Completed { session, .. }
| Self::Aborted { session, .. }
| Self::Refused { session, .. }
| Self::StateChanged { session, .. } => *session,
}
}
#[must_use]
pub const fn at(&self) -> Instant {
match self {
Self::Opened { at, .. }
| Self::Negotiated { at, .. }
| Self::Operating { at, .. }
| Self::Registered { at, .. }
| Self::Completed { at, .. }
| Self::Aborted { at, .. }
| Self::Refused { at, .. }
| Self::StateChanged { at, .. } => *at,
}
}
}
impl fmt::Display for SessionEvent {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Opened { session, at } => write!(formatter, "{at} {session} opened"),
Self::Negotiated {
session,
at,
version,
echoed,
} => write!(
formatter,
"{at} {session} negotiated {version} ({})",
if *echoed { "echoed" } else { "counter-offered" }
),
Self::Operating { session, at } => write!(formatter, "{at} {session} operating"),
Self::Registered {
session,
at,
id,
method,
origin,
} => write!(
formatter,
"{at} {session} {} {} as {}",
origin_label(*origin),
method,
id.redacted()
),
Self::Completed {
session,
at,
id,
method,
} => write!(
formatter,
"{at} {session} completed {} as {}",
method,
id.redacted()
),
Self::Aborted {
session,
at,
id,
method,
reason,
} => write!(
formatter,
"{at} {session} aborted {} ({reason}) as {}",
method,
id.redacted()
),
Self::Refused {
session,
at,
id,
method,
state,
} => write!(
formatter,
"{at} {session} refused {method} while {state}{}",
id.as_ref()
.map_or_else(String::new, |id| format!(" as {}", id.redacted()))
),
Self::StateChanged { session, at, state } => {
write!(formatter, "{at} {session} is {state}")
}
}
}
}
const fn origin_label(origin: RequestOrigin) -> &'static str {
match origin {
RequestOrigin::Inbound => "received",
RequestOrigin::Outbound => "sent",
}
}
pub trait EventLog {
fn record(&self, event: SessionEvent);
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NullEventLog;
impl EventLog for NullEventLog {
fn record(&self, _event: SessionEvent) {}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use super::{EventLog, NullEventLog, SessionEvent};
use crate::domain::RequestId;
use crate::domain::session::{
AbortReason, CancelReason, Instant, RequestOrigin, SessionId, SessionState,
};
fn at() -> Instant {
Instant::from_duration(Duration::from_millis(1_500))
}
fn session() -> SessionId {
SessionId::new(4)
}
#[test]
fn the_display_form_redacts_but_the_value_does_not() {
let id = RequestId::text("super-secret-credential");
let event = SessionEvent::Completed {
session: session(),
at: at(),
id: id.clone(),
method: Arc::from("tools/call"),
};
let line = event.to_string();
assert!(!line.contains("super-secret-credential"), "{line}");
assert!(line.contains(&id.redacted()), "{line}");
assert_matches::assert_matches!(&event, SessionEvent::Completed { id: kept, .. } if *kept == id);
}
#[test]
fn every_variant_renders_a_line() {
let id = RequestId::number(1);
let events = [
SessionEvent::Opened {
session: session(),
at: at(),
},
SessionEvent::Negotiated {
session: session(),
at: at(),
version: String::from("2025-06-18"),
echoed: true,
},
SessionEvent::Negotiated {
session: session(),
at: at(),
version: String::from("2026-07-28"),
echoed: false,
},
SessionEvent::Operating {
session: session(),
at: at(),
},
SessionEvent::Registered {
session: session(),
at: at(),
id: id.clone(),
method: Arc::from("tools/call"),
origin: RequestOrigin::Inbound,
},
SessionEvent::Registered {
session: session(),
at: at(),
id: id.clone(),
method: Arc::from("sampling/createMessage"),
origin: RequestOrigin::Outbound,
},
SessionEvent::Completed {
session: session(),
at: at(),
id: id.clone(),
method: Arc::from("tools/call"),
},
SessionEvent::Aborted {
session: session(),
at: at(),
id: id.clone(),
method: Arc::from("tools/call"),
reason: AbortReason::Cancelled(Some(CancelReason::new("user"))),
},
SessionEvent::Refused {
session: session(),
at: at(),
id: Some(id),
method: Arc::from("tools/call"),
state: SessionState::Uninitialised,
},
SessionEvent::Refused {
session: session(),
at: at(),
id: None,
method: Arc::from("notifications/initialized"),
state: SessionState::Operating,
},
SessionEvent::StateChanged {
session: session(),
at: at(),
state: SessionState::Closed,
},
];
for event in &events {
let line = event.to_string();
assert!(line.contains("session#4"), "{line}");
assert!(!line.is_empty());
}
}
#[test]
fn the_null_log_accepts_and_discards() {
let log = NullEventLog;
log.record(SessionEvent::Opened {
session: session(),
at: at(),
});
}
}