use crate::context::ir::{build_tool_call, build_tool_result, extract_text};
use crate::context::{ContextReader, for_each_jsonl_record};
use crate::message::{
Context, ContextListing, ConversationMessage, Entry, Image, Part, ProviderId, Reasoning,
};
use anyhow::Context as _;
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
pub struct ClaudeReader {
file_path: PathBuf,
}
impl ClaudeReader {
pub fn new(file_path: PathBuf) -> Self {
Self { file_path }
}
}
fn session_id(file_path: &Path) -> String {
let base = crate::resolver::claude_projects_base();
let Ok(rel) = file_path.strip_prefix(&base) else {
return file_path.file_stem().map_or_else(
|| "unknown".to_string(),
|stem| stem.to_string_lossy().into_owned(),
);
};
let rel = rel.with_extension("");
if rel.as_os_str().is_empty() {
return "unknown".to_string();
}
rel.to_string_lossy().into_owned()
}
impl ContextReader for ClaudeReader {
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
let file_path = &self.file_path;
let file =
fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
let reader = BufReader::new(file);
let mut sid = String::new();
let mut agent_sid = String::new();
let mut cwd = String::new();
let mut timestamp = String::new();
for line in reader.lines() {
let line = line.with_context(|| format!("read {}", file_path.display()))?;
if line.trim().is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<Value>(&line) else {
continue;
};
if sid.is_empty()
&& let Some(value) = entry["sessionId"].as_str()
{
sid = value.to_string();
}
if agent_sid.is_empty()
&& let Some(value) = entry["agentId"].as_str()
{
agent_sid = value.to_string();
}
if cwd.is_empty()
&& let Some(value) = entry["cwd"].as_str()
{
cwd = value.to_string();
}
if timestamp.is_empty()
&& let Some(value) = entry["timestamp"].as_str()
{
timestamp = value.to_string();
}
if !sid.is_empty() && !cwd.is_empty() && !timestamp.is_empty() {
break;
}
}
let provider_id = ProviderId {
label: None,
cwd: PathBuf::from(&cwd),
count: 0,
from: if timestamp.is_empty() {
None
} else {
DateTime::parse_from_rfc3339(×tamp)
.ok()
.map(|dt| dt.with_timezone(&Utc))
},
until: None,
};
let (id, parent_id) = if !sid.is_empty() && !agent_sid.is_empty() {
(format!("{sid}/agent-{agent_sid}"), Some(sid))
} else if !sid.is_empty() {
(sid, None)
} else {
(session_id(file_path), None)
};
Ok(vec![ContextListing {
id,
provider_id,
path: file_path.clone(),
parent_id,
}])
}
fn delete_context(&self, _context_id: &str) -> anyhow::Result<()> {
fs::remove_file(&self.file_path)
.with_context(|| format!("remove {}", self.file_path.display()))
}
fn read_context(&self, _context_id: &str) -> anyhow::Result<Context> {
let mut entries = Vec::new();
let mut messages = Vec::new();
let mut cwd = String::new();
let mut tool_names: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for_each_jsonl_record(&self.file_path, |line_num, entry_json| {
if cwd.is_empty()
&& let Some(value) = entry_json["cwd"].as_str()
{
cwd = value.to_string();
}
let Some(message) = entry_json.get("message").filter(|m| m.is_object()) else {
return;
};
let entry_id = match entry_json["uuid"].as_str() {
Some(uuid) => uuid.to_string(),
None => format!("claude-{line_num}"),
};
let parent_id = entry_json["parentUuid"].as_str().unwrap_or("").to_string();
let (role, parts) = claude_build_message(message, &mut tool_names);
messages.push(ConversationMessage::new(entry_id.clone(), role, parts));
entries.push(Entry {
id: entry_id,
parent_id,
});
})?;
Ok(Context {
entries,
messages,
cwd: (!cwd.is_empty()).then_some(cwd),
})
}
}
fn claude_build_message(
message: &Value,
tool_names: &mut std::collections::HashMap<String, String>,
) -> (String, Vec<Part>) {
let role = message["role"].as_str().unwrap_or("unknown");
let content = message.get("content");
if role == "assistant" {
let mut parts = Vec::new();
if let Some(Value::Array(blocks)) = content {
for block in blocks {
match block["type"].as_str() {
Some("text") => {
parts.push(Part::Text(block["text"].as_str().unwrap_or("").to_string()));
}
Some("thinking") => {
parts.push(Part::Reasoning(Reasoning {
text: block["thinking"].as_str().unwrap_or("").to_string(),
signature: block["signature"].as_str().unwrap_or("").to_string(),
redacted: false,
}));
}
Some("redacted_thinking") => {
parts.push(Part::Reasoning(Reasoning {
text: block["data"]
.as_str()
.or_else(|| block["thinking"].as_str())
.unwrap_or("")
.to_string(),
signature: block["signature"].as_str().unwrap_or("").to_string(),
redacted: true,
}));
}
Some("tool_use") => {
let name = block["name"].as_str().unwrap_or("");
let id = block["id"].as_str().unwrap_or("");
if !id.is_empty() {
tool_names.insert(id.to_string(), name.to_string());
}
parts.push(Part::ToolCall(build_tool_call(
id,
name,
block.get("input"),
)));
}
Some("image") => {
if let Some(image) = claude_image(block) {
parts.push(Part::Image(image));
}
}
_ => {}
}
}
} else if let Some(Value::String(text)) = content {
parts.push(Part::Text(text.clone()));
}
return ("assistant".to_string(), parts);
}
if let Some(Value::Array(blocks)) = content {
for block in blocks {
if block["type"].as_str() == Some("tool_result") {
let tool_use_id = block["tool_use_id"].as_str().unwrap_or("");
let tool_name = tool_names
.get(tool_use_id)
.map_or_else(|| "tool".to_string(), Clone::clone);
let result_content = extract_text(block.get("content"));
let is_error = block["is_error"].as_bool().unwrap_or(false);
return (
role.to_string(),
vec![Part::ToolResult(build_tool_result(
tool_use_id,
tool_name,
result_content,
is_error,
))],
);
}
}
}
let mut parts = Vec::new();
if let Some(Value::Array(blocks)) = content {
for block in blocks {
if block["type"].as_str() == Some("image")
&& let Some(image) = claude_image(block)
{
parts.push(Part::Image(image));
}
}
}
let text = extract_text(content);
if !text.is_empty() || parts.is_empty() {
parts.insert(0, Part::Text(text));
}
(role.to_string(), parts)
}
fn claude_image(block: &Value) -> Option<Image> {
let source = block.get("source")?;
let data = source["data"].as_str().or_else(|| source["url"].as_str())?;
Some(Image {
mime_type: source["media_type"].as_str().unwrap_or("").to_string(),
data: data.to_string(),
})
}