use crate::event::{EventBody, TraceEvent};
#[derive(Debug, thiserror::Error)]
pub enum JournalError {
#[error("journal line {line} is not a valid trace event: {source}")]
Malformed {
line: usize,
#[source]
source: serde_json::Error,
},
#[error("journal contains no events")]
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Journal {
pub events: Vec<TraceEvent>,
}
impl Journal {
pub fn from_ndjson(input: &str) -> Result<Self, JournalError> {
let mut events = Vec::new();
for (index, line) in input.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let event: TraceEvent =
serde_json::from_str(line).map_err(|source| JournalError::Malformed {
line: index + 1,
source,
})?;
events.push(event);
}
if events.is_empty() {
return Err(JournalError::Empty);
}
Ok(Self { events })
}
pub fn describe(&self) -> String {
let session = self
.events
.first()
.map(|event| event.session.as_str())
.unwrap_or("<empty>");
match self.events.first().map(|event| &event.body) {
Some(EventBody::SessionStart { agent, harness, .. }) => format!(
"session {session} — agent {agent} (harness {harness}), {} event(s)",
self.events.len()
),
_ => format!("session {session}, {} event(s)", self.events.len()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TWO_LINES: &str = concat!(
r#"{"seq":1,"at":"2026-07-23T09:00:00Z","session":"sess_1","event":"session_start","agent":"example-agent","harness":"stella/0.9"}"#,
"\n",
r#"{"seq":2,"at":"2026-07-23T09:00:01Z","session":"sess_1","event":"session_end","outcome":"completed"}"#,
"\n",
);
#[test]
fn a_well_formed_journal_parses_in_file_order() {
let journal = Journal::from_ndjson(TWO_LINES).unwrap();
assert_eq!(journal.events.len(), 2);
assert_eq!(journal.events[0].seq, 1);
assert_eq!(journal.events[1].body.kind(), "session_end");
assert_eq!(
journal.describe(),
"session sess_1 — agent example-agent (harness stella/0.9), 2 event(s)"
);
}
#[test]
fn blank_lines_are_permitted_but_garbage_names_its_line() {
let with_blank = format!("\n{TWO_LINES}\n");
assert!(Journal::from_ndjson(&with_blank).is_ok());
let with_garbage = format!("{TWO_LINES}not json {{{{\n");
let error = Journal::from_ndjson(&with_garbage).unwrap_err();
assert!(matches!(error, JournalError::Malformed { line: 3, .. }));
}
#[test]
fn an_empty_journal_is_an_error_not_a_vacuous_pass() {
assert!(matches!(
Journal::from_ndjson("\n\n"),
Err(JournalError::Empty)
));
}
}