use super::row::{EventRow, event_row};
use super::{SessionIndexError, clamp_i64};
use crate::{FileFingerprint, SessionEvent, SessionLog, SessionLogEntry, SessionLogError, SessionMeta, UserEvent};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub(crate) struct AetherSession {
pub source_path: PathBuf,
pub fingerprint: FileFingerprint,
pub meta: SessionMeta,
pub events: Vec<EventRow>,
pub parse_errors: Vec<SessionParseError>,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionParseError {
pub source_path: PathBuf,
pub session_id: Option<String>,
pub line_number: Option<i64>,
pub error: String,
pub line_excerpt: Option<String>,
}
impl AetherSession {
pub(crate) fn parse(path: impl AsRef<Path>) -> Result<Self, SessionIndexError> {
let path = path.as_ref();
let fingerprint = FileFingerprint::read(path)?;
parse_session_file(path, fingerprint)
}
}
fn parse_session_file(path: &Path, fingerprint: FileFingerprint) -> Result<AetherSession, SessionIndexError> {
let mut log = SessionLog::open(path).map_err(|e| match e {
SessionLogError::Io(io) => SessionIndexError::Io(io),
SessionLogError::MissingMetadata => {
SessionIndexError::InvalidMetadata { path: path.to_path_buf(), message: "missing metadata line".into() }
}
SessionLogError::InvalidMetadata { line_number, source } => {
SessionIndexError::JsonLine { path: path.to_path_buf(), line_number, source }
}
})?;
let meta = log.meta.clone();
if meta.session_id.trim().is_empty() {
return Err(SessionIndexError::InvalidMetadata {
path: path.to_path_buf(),
message: "sessionId is empty".into(),
});
}
let mut events = Vec::new();
let mut parse_errors = Vec::new();
let mut current_turn_index: Option<i64> = None;
while let Some(entry) = log.next_entry()? {
match entry {
SessionLogEntry::Persisted { line, event } => {
if matches!(event.as_ref(), SessionEvent::User(UserEvent::Message { .. })) {
current_turn_index = Some(current_turn_index.map_or(0, |turn| turn + 1));
}
events.push(event_row(
&meta.session_id,
clamp_i64(events.len()),
clamp_i64(line.line_number),
current_turn_index,
event.as_ref(),
line.raw,
));
}
SessionLogEntry::Transient { .. } => {}
SessionLogEntry::Malformed { line, error } => parse_errors.push(SessionParseError {
source_path: path.to_path_buf(),
session_id: Some(meta.session_id.clone()),
line_number: Some(clamp_i64(line.line_number)),
error: error.to_string(),
line_excerpt: Some(line.raw.chars().take(240).collect()),
}),
}
}
Ok(AetherSession { source_path: path.to_path_buf(), fingerprint, meta, events, parse_errors })
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn discovery_filters_and_sorts_jsonl_files() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("b.jsonl"), "").unwrap();
fs::write(temp.path().join("a.jsonl"), "").unwrap();
fs::write(temp.path().join("prompt-history.jsonl"), "").unwrap();
fs::write(temp.path().join("notes.txt"), "").unwrap();
let files = crate::discover_session_files(temp.path()).unwrap();
assert_eq!(files.len(), 2);
assert!(files[0].path.ends_with("a.jsonl"));
assert!(files[1].path.ends_with("b.jsonl"));
}
#[test]
fn malformed_event_line_records_parse_error() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("s.jsonl");
let mut file = File::create(&path).unwrap();
writeln!(file, r#"{{"sessionId":"s","cwd":"/tmp","model":"m","createdAt":"now"}}"#).unwrap();
writeln!(file, r#"{{"kind":"user","data":{{"type":"message","message_id":"user","content":[{{"type":"text","text":"hi"}}]}}}}"#)
.unwrap();
writeln!(file, "not json").unwrap();
writeln!(
file,
r#"{{"kind":"agent","data":{{"category":"message","event":{{"type":"text","message_id":"m","chunk":"ok","is_complete":true}}}}}}"#
)
.unwrap();
let parsed = AetherSession::parse(&path).unwrap();
assert_eq!(parsed.events.len(), 2);
assert_eq!(parsed.parse_errors.len(), 1);
assert_eq!(parsed.events[0].turn_index, Some(0));
assert_eq!(parsed.events[1].turn_index, Some(0));
}
#[test]
fn streaming_events_are_dropped_during_parse() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("s.jsonl");
let mut file = File::create(&path).unwrap();
writeln!(file, r#"{{"sessionId":"s","cwd":"/tmp","model":"m","createdAt":"now"}}"#).unwrap();
writeln!(
file,
r#"{{"kind":"agent","data":{{"category":"message","event":{{"type":"text","message_id":"m","chunk":"part","is_complete":false}}}}}}"#
)
.unwrap();
writeln!(
file,
r#"{{"kind":"agent","data":{{"category":"tool","event":{{"type":"call_update","tool_call_id":"1","chunk":"x"}}}}}}"#
)
.unwrap();
writeln!(
file,
r#"{{"kind":"agent","data":{{"category":"message","event":{{"type":"text","message_id":"m","chunk":"final","is_complete":true}}}}}}"#
)
.unwrap();
let parsed = AetherSession::parse(&path).unwrap();
assert_eq!(parsed.events.len(), 1);
assert_eq!(parsed.events[0].content.as_deref(), Some("final"));
}
}