use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionSummary {
pub ulid: String,
pub started_at_ms: i64,
pub ended_at_ms: Option<i64>,
pub engine_base_url: Option<String>,
pub cli_version: String,
pub parent_ulid: Option<String>,
pub n_events: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReplayKind {
Prompt,
System,
Command,
Warn,
Alert,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplayEvent {
pub kind: ReplayKind,
pub at_ms: i64,
pub text: String,
}
#[derive(Debug, Clone)]
pub enum SessionError {
NotFound,
Io(String),
}
impl fmt::Display for SessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound => write!(f, "session not found"),
Self::Io(s) => write!(f, "session store error: {s}"),
}
}
}
impl std::error::Error for SessionError {}
pub trait SessionSource: Send + Sync + 'static {
fn current_ulid(&self) -> Option<String>;
fn list(&self, limit: u32) -> Result<Vec<SessionSummary>, SessionError>;
fn find(&self, needle: &str) -> Result<SessionSummary, SessionError>;
fn list_events(&self, ulid: &str, limit: u32) -> Result<Vec<ReplayEvent>, SessionError>;
fn save_label(&self, ulid: &str, label: &str) -> Result<(), SessionError>;
fn fork_from_current(&self) -> Result<Option<String>, SessionError>;
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::sync::Mutex;
#[derive(Default, Debug)]
pub struct MockSessions {
pub inner: Mutex<MockInner>,
}
#[derive(Default, Debug)]
pub struct MockInner {
pub sessions: Vec<SessionSummary>,
pub events: std::collections::HashMap<String, Vec<ReplayEvent>>,
pub labels: std::collections::HashMap<String, String>,
pub current: Option<String>,
pub fail_with: Option<SessionError>,
}
impl MockSessions {
pub fn with_current(ulid: &str) -> Self {
Self {
inner: Mutex::new(MockInner {
current: Some(ulid.to_string()),
..MockInner::default()
}),
}
}
pub fn insert(&self, summary: SessionSummary, events: Vec<ReplayEvent>) {
let mut g = self.inner.lock().unwrap();
g.events.insert(summary.ulid.clone(), events);
g.sessions.push(summary);
g.sessions
.sort_by(|a, b| b.started_at_ms.cmp(&a.started_at_ms));
}
}
impl SessionSource for MockSessions {
fn current_ulid(&self) -> Option<String> {
self.inner.lock().unwrap().current.clone()
}
fn list(&self, limit: u32) -> Result<Vec<SessionSummary>, SessionError> {
let g = self.inner.lock().unwrap();
if let Some(e) = g.fail_with.clone() {
return Err(e);
}
Ok(g.sessions
.iter()
.take(usize::try_from(limit).unwrap_or(usize::MAX))
.cloned()
.collect())
}
fn find(&self, needle: &str) -> Result<SessionSummary, SessionError> {
let g = self.inner.lock().unwrap();
if let Some(e) = g.fail_with.clone() {
return Err(e);
}
let ulid = g
.labels
.get(needle)
.cloned()
.or_else(|| {
g.sessions
.iter()
.find(|s| s.ulid == needle || s.ulid.starts_with(needle))
.map(|s| s.ulid.clone())
})
.ok_or(SessionError::NotFound)?;
g.sessions
.iter()
.find(|s| s.ulid == ulid)
.cloned()
.ok_or(SessionError::NotFound)
}
fn list_events(&self, ulid: &str, limit: u32) -> Result<Vec<ReplayEvent>, SessionError> {
let g = self.inner.lock().unwrap();
if let Some(e) = g.fail_with.clone() {
return Err(e);
}
Ok(g.events
.get(ulid)
.cloned()
.unwrap_or_default()
.into_iter()
.take(usize::try_from(limit).unwrap_or(usize::MAX))
.collect())
}
fn save_label(&self, ulid: &str, label: &str) -> Result<(), SessionError> {
let mut g = self.inner.lock().unwrap();
if let Some(e) = g.fail_with.clone() {
return Err(e);
}
g.labels.insert(label.to_string(), ulid.to_string());
Ok(())
}
fn fork_from_current(&self) -> Result<Option<String>, SessionError> {
let mut g = self.inner.lock().unwrap();
if let Some(e) = g.fail_with.clone() {
return Err(e);
}
let Some(parent) = g.current.clone() else {
return Ok(None);
};
let child = format!("{parent}-fork");
let next_ts = g.sessions.first().map_or(0, |s| s.started_at_ms + 1);
g.sessions.insert(
0,
SessionSummary {
ulid: child.clone(),
started_at_ms: next_ts,
ended_at_ms: None,
engine_base_url: None,
cli_version: "test".into(),
parent_ulid: Some(parent),
n_events: 0,
},
);
g.current = Some(child.clone());
Ok(Some(child))
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::MockSessions;
use super::*;
#[test]
fn mock_list_returns_newest_first_and_respects_limit() {
let m = MockSessions::with_current("01HA");
m.insert(
SessionSummary {
ulid: "01HA".into(),
started_at_ms: 1,
ended_at_ms: None,
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 0,
},
vec![],
);
m.insert(
SessionSummary {
ulid: "01HB".into(),
started_at_ms: 2,
ended_at_ms: Some(3),
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 2,
},
vec![],
);
let rows = m.list(10).unwrap();
assert_eq!(
rows.iter().map(|s| s.ulid.as_str()).collect::<Vec<_>>(),
vec!["01HB", "01HA"]
);
assert_eq!(m.list(1).unwrap().len(), 1);
}
#[test]
fn mock_find_resolves_ulid_and_label() {
let m = MockSessions::with_current("01HA");
m.insert(
SessionSummary {
ulid: "01HA".into(),
started_at_ms: 1,
ended_at_ms: None,
engine_base_url: None,
cli_version: "0.3.0".into(),
parent_ulid: None,
n_events: 0,
},
vec![],
);
m.save_label("01HA", "scratch").unwrap();
assert_eq!(m.find("01HA").unwrap().ulid, "01HA");
assert_eq!(m.find("scratch").unwrap().ulid, "01HA");
assert!(matches!(
m.find("nope").unwrap_err(),
SessionError::NotFound
));
}
#[test]
fn mock_fork_returns_none_when_no_current_session() {
let m = MockSessions {
inner: std::sync::Mutex::new(super::test_support::MockInner::default()),
};
assert_eq!(m.fork_from_current().unwrap(), None);
}
}