use crate::context::ContextReader;
use crate::message::{
AssistantResponse, BashOutput, Context, ContextListing, ConversationMessage, Entry,
MessageKind, TextContent, ToolCall, ToolResultData,
};
use anyhow::Context as _;
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
pub struct JsonlReader {
file_path: Option<PathBuf>,
}
impl JsonlReader {
pub fn new(file_path: PathBuf) -> Self {
Self {
file_path: Some(file_path),
}
}
pub fn empty() -> Self {
Self { file_path: None }
}
}
impl ContextReader for JsonlReader {
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
let Some(file_path) = &self.file_path else {
return Ok(Vec::new());
};
let file =
fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
let mut reader = BufReader::new(file);
let mut first_line = String::new();
reader
.read_line(&mut first_line)
.with_context(|| format!("read {}", file_path.display()))?;
let first_line = first_line.trim_end().to_string();
if first_line.is_empty() {
anyhow::bail!("empty file");
}
let session: Value = serde_json::from_str(&first_line)?;
if session["type"].as_str() != Some("session") {
anyhow::bail!("first line is not a session header");
}
let id = session["id"].as_str().unwrap_or("unknown").to_string();
let cwd = session["cwd"].as_str().unwrap_or("").to_string();
let detail = if cwd.is_empty() {
String::new()
} else {
format!("cwd: {cwd}")
};
Ok(vec![ContextListing {
id,
detail,
path: self.file_path.clone(),
}])
}
fn read_context(&self, _context_id: &str) -> anyhow::Result<Context> {
let Some(file_path) = &self.file_path else {
anyhow::bail!("no sessions found");
};
let file =
fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
let mut messages = Vec::new();
for (line_num, line_result) in reader.lines().enumerate() {
let line = line_result
.with_context(|| format!("{}:{}: read error", file_path.display(), line_num + 1))?;
if line.trim().is_empty() {
continue;
}
let entry_json: Value = serde_json::from_str(&line).with_context(|| {
format!("{}:{}: JSON parse error", file_path.display(), line_num + 1)
})?;
let entry_type = entry_json["type"].as_str().unwrap_or("").to_string();
if entry_type == "session" {
continue;
}
let id = entry_json["id"].as_str().unwrap_or("").to_string();
let parent_id = entry_json["parentId"].as_str().unwrap_or("").to_string();
if let Some(raw_msg) = entry_json.get("message") {
let msg_type = entry_type.as_str();
let kind = jsonl_build_message_kind(msg_type, raw_msg);
messages.push(ConversationMessage {
entry_id: id.clone(),
kind,
});
}
entries.push(Entry {
line: line_num,
r#type: entry_type,
id,
parent_id,
});
}
Ok(Context {
path: file_path.clone(),
entries,
messages,
})
}
}
fn jsonl_build_message_kind(msg_type: &str, msg: &Value) -> MessageKind {
let role = msg["role"].as_str().unwrap_or(msg_type).to_string();
let content = msg.get("content");
match role.as_str() {
"assistant" => {
let mut thinking = Vec::new();
let mut tool_calls = Vec::new();
let mut text = String::new();
if let Some(content) = content
&& let Some(arr) = content.as_array()
{
for part in arr {
match part["type"].as_str() {
Some("text") => {
text.push_str(part["text"].as_str().unwrap_or(""));
}
Some("thinking" | "redacted_thinking") => {
if let Some(t) = part["text"].as_str() {
thinking.push(t.to_string());
}
}
Some("toolCall" | "tool_use") => {
let tc_name = part["name"].as_str().unwrap_or("").to_string();
let tc_args = part
.get("arguments")
.or_else(|| part.get("input"))
.cloned()
.unwrap_or(Value::Null);
tool_calls.push(ToolCall {
name: tc_name,
arguments: tc_args,
});
}
_ => {}
}
}
}
MessageKind::AssistantResponse(AssistantResponse {
thinking,
tool_calls,
text,
})
}
"toolResult" | "bashExecution" => {
let content_str = if let Some(content) = content {
if let Some(arr) = content.as_array() {
arr.iter()
.filter_map(|p| p["text"].as_str())
.collect::<Vec<_>>()
.join("\n")
} else if let Some(s) = content.as_str() {
s.to_string()
} else {
content.to_string()
}
} else if let Some(s) = msg["text"].as_str() {
s.to_string()
} else {
String::new()
};
if role == "bashExecution" {
let command = msg["command"].as_str().unwrap_or("").to_string();
MessageKind::BashOutput(BashOutput {
command,
output: content_str,
})
} else {
let tool_name = msg["toolName"].as_str().unwrap_or("").to_string();
let is_error = msg["isError"].as_bool().unwrap_or(false);
MessageKind::ToolResultData(ToolResultData {
tool_name,
content: content_str,
is_error,
})
}
}
_ => {
let text = extract_text_content(content);
MessageKind::TextContent(TextContent { role, text })
}
}
}
fn extract_text_content(content: Option<&Value>) -> String {
match content {
Some(Value::Array(arr)) => arr
.iter()
.filter(|p| p["type"].as_str() == Some("text"))
.map(|p| p["text"].as_str().unwrap_or("").to_string())
.collect::<Vec<_>>()
.join("\n"),
Some(Value::String(s)) => s.clone(),
_ => String::new(),
}
}