use crate::canonical::{
CanonicalSchema, CanonicalSession, EventBlock, EventLinks, EventMetadata, EventRole,
EventSource, ExportedSession, ImportedSession, MappingDirection, MappingDisposition,
MappingIssue, MappingIssueLevel, MappingReport, ProviderSessionRef, SessionContext,
SessionEvent, SessionEventKind, SessionIdentity, SessionProvenance, UsageStats,
};
use crate::provider::{
canonical_block_text, canonical_event_text, canonical_export_result, canonical_session_title,
Provider, ProviderCapabilities, ProviderSessionSummary,
};
use crate::utils::{
encode_project_dir, extract_text, parse_timestamp_to_ms, path_basename, truncate_summary,
};
use anyhow::{Context, Result};
use chrono::Utc;
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use uuid::Uuid;
use walkdir::WalkDir;
pub struct ClaudeProvider;
const PROVIDER_ID: &str = "claude";
const TITLE_MAX_CHARS: usize = 80;
impl Provider for ClaudeProvider {
fn id(&self) -> &'static str {
PROVIDER_ID
}
fn name(&self) -> &'static str {
"claude"
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities::full_session_management()
}
fn scan_sessions(&self) -> Result<Vec<ProviderSessionSummary>> {
let root = get_claude_config_dir().join("projects");
if !root.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in WalkDir::new(&root)
.max_depth(3)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("agent-"))
.unwrap_or(false)
{
continue;
}
if let Some(meta) = parse_session(path) {
sessions.push(meta);
}
}
Ok(sessions)
}
fn import_session(&self, source_path: &str) -> Result<ImportedSession> {
import_canonical_session(Path::new(source_path))
}
fn export_session(
&self,
session: &CanonicalSession,
target_dir: &Path,
) -> Result<ExportedSession> {
let session_id = export_canonical_session(session, target_dir)?;
Ok(canonical_export_result(
PROVIDER_ID,
session_id.clone(),
self.resume_command(&session_id),
))
}
fn delete_session(&self, session_id: &str) -> Result<()> {
let projects_dir = get_claude_config_dir().join("projects");
if !projects_dir.exists() {
anyhow::bail!("Claude projects directory not found");
}
let mut found = false;
for entry in WalkDir::new(&projects_dir)
.max_depth(3)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if path.file_stem().and_then(|s| s.to_str()) == Some(session_id) {
std::fs::remove_file(path).with_context(|| {
format!("Failed to remove session file: {}", path.display())
})?;
if let Some(parent) = path.parent() {
let sidecar = parent.join(session_id);
if sidecar.exists() {
std::fs::remove_dir_all(&sidecar).with_context(|| {
format!("Failed to remove sidecar directory: {}", sidecar.display())
})?;
}
}
found = true;
break;
}
}
if !found {
anyhow::bail!("Claude session not found: {}", session_id);
}
Ok(())
}
fn rename_session(&self, session_id: &str, new_title: &str) -> Result<()> {
let projects_dir = get_claude_config_dir().join("projects");
if !projects_dir.exists() {
anyhow::bail!("Claude projects directory not found");
}
let mut found_path: Option<PathBuf> = None;
for entry in WalkDir::new(&projects_dir)
.max_depth(3)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if path.file_stem().and_then(|s| s.to_str()) == Some(session_id) {
found_path = Some(path.to_path_buf());
break;
}
}
let path =
found_path.with_context(|| format!("Claude session not found: {}", session_id))?;
let content = std::fs::read_to_string(&path)?;
let mut new_lines = Vec::new();
let mut title_updated = false;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let mut value: Value = serde_json::from_str(line)?;
if value.get("type").and_then(|v| v.as_str()) == Some("custom-title") {
if let Value::Object(ref mut map) = value {
map.insert(
"customTitle".to_string(),
Value::String(new_title.to_string()),
);
title_updated = true;
}
new_lines.push(serde_json::to_string(&value)?);
} else {
new_lines.push(line.to_string());
}
}
if !title_updated {
let title_line = serde_json::json!({
"type": "custom-title",
"customTitle": new_title,
"sessionId": session_id,
});
let mut inserted = false;
for (i, line) in new_lines.iter().enumerate() {
let v: Value = serde_json::from_str(line)?;
if v.get("type").and_then(|v| v.as_str()) == Some("permission-mode") {
new_lines.insert(i + 1, serde_json::to_string(&title_line)?);
inserted = true;
break;
}
}
if !inserted {
new_lines.insert(0, serde_json::to_string(&title_line)?);
}
}
std::fs::write(&path, new_lines.join("\n") + "\n")?;
Ok(())
}
fn resume_command(&self, session_id: &str) -> Option<String> {
Some(format!("claude --resume {}", session_id))
}
fn session_size(&self, session_id: &str) -> Result<u64> {
let path = Path::new(session_id);
if path.exists() {
Ok(std::fs::metadata(path)?.len())
} else {
Ok(0)
}
}
}
fn get_claude_config_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".claude"))
.unwrap_or_else(|| PathBuf::from(".claude"))
}
fn get_git_branch(dir: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args([
"-C",
&dir.to_string_lossy(),
"rev-parse",
"--abbrev-ref",
"HEAD",
])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn export_canonical_session(session: &CanonicalSession, target_dir: &Path) -> Result<String> {
let session_id = Uuid::new_v4().to_string();
let encoded_dir = encode_project_dir(&target_dir.to_string_lossy());
let claude_projects_dir = get_claude_config_dir().join("projects").join(&encoded_dir);
std::fs::create_dir_all(&claude_projects_dir)?;
let file_path = claude_projects_dir.join(format!("{}.jsonl", session_id));
let sidecar_dir = claude_projects_dir.join(&session_id);
std::fs::create_dir_all(&sidecar_dir)?;
std::fs::create_dir_all(sidecar_dir.join("tool-results"))?;
std::fs::create_dir_all(sidecar_dir.join("subagents"))?;
let mut file = File::create(&file_path)?;
let title = canonical_session_title(session);
let project_dir_str = target_dir.to_string_lossy().to_string();
let version = "2.1.116";
let git_branch = get_git_branch(target_dir).unwrap_or_else(|| "main".to_string());
writeln!(
file,
"{}",
serde_json::to_string(&serde_json::json!({
"type": "permission-mode",
"permissionMode": "bypassPermissions",
"sessionId": session_id,
}))?
)?;
writeln!(
file,
"{}",
serde_json::to_string(&serde_json::json!({
"type": "custom-title",
"customTitle": title,
"sessionId": session_id,
}))?
)?;
let mut prev_uuid: Option<String> = None;
for event in &session.events {
let msg_uuid = Uuid::new_v4().to_string();
let timestamp = event.timestamp.to_rfc3339();
let line_type = if event.role == EventRole::Assistant {
"assistant"
} else {
"user"
};
let content = if event.role == EventRole::Assistant {
Value::Array(
event
.blocks
.iter()
.filter_map(canonical_block_to_claude_content)
.collect(),
)
} else if event
.blocks
.iter()
.all(|block| matches!(block, EventBlock::ToolResult { .. }))
{
Value::Array(
event
.blocks
.iter()
.filter_map(canonical_block_to_claude_content)
.collect(),
)
} else {
Value::String(canonical_event_text(event))
};
let message_role = if event.role == EventRole::Assistant {
"assistant"
} else {
"user"
};
let mut message = serde_json::json!({
"role": message_role,
"content": content,
});
if event.role == EventRole::Assistant {
message["id"] = Value::String(format!(
"msg_{}",
Uuid::new_v4()
.to_string()
.replace("-", "")
.chars()
.take(20)
.collect::<String>()
));
message["type"] = Value::String("message".to_string());
if let Some(model) = &event.metadata.model {
message["model"] = Value::String(model.clone());
}
message["stop_reason"] = Value::String(
if event
.blocks
.iter()
.any(|block| matches!(block, EventBlock::ToolCall { .. }))
{
"tool_use"
} else {
"end_turn"
}
.to_string(),
);
if let Some(usage) = &event.metadata.usage {
message["usage"] = serde_json::json!({
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
});
}
}
let line = serde_json::json!({
"parentUuid": prev_uuid,
"isSidechain": false,
"message": message,
"type": line_type,
"uuid": msg_uuid,
"timestamp": timestamp,
"userType": "external",
"entrypoint": "cli",
"cwd": project_dir_str,
"sessionId": session_id,
"version": version,
"gitBranch": git_branch,
});
writeln!(file, "{}", serde_json::to_string(&line)?)?;
prev_uuid = Some(msg_uuid);
}
Ok(session_id)
}
fn canonical_block_to_claude_content(block: &EventBlock) -> Option<Value> {
match block {
EventBlock::Text { text } => Some(serde_json::json!({
"type": "text",
"text": text,
})),
EventBlock::Thinking { text, signature } => {
let mut value = serde_json::json!({
"type": "thinking",
"thinking": text,
});
if let Some(signature) = signature {
value["signature"] = Value::String(signature.clone());
}
Some(value)
}
EventBlock::ToolCall {
tool_call_id,
name,
input,
} => {
let mut value = serde_json::json!({
"type": "tool_use",
"id": tool_call_id,
"name": name,
});
if let Some(input) = input {
value["input"] = input.clone();
}
Some(value)
}
EventBlock::ToolResult {
tool_call_id,
content,
is_error,
} => Some(serde_json::json!({
"type": "tool_result",
"tool_use_id": tool_call_id,
"content": content,
"is_error": is_error,
})),
_ => {
let text = canonical_block_text(block);
(!text.trim().is_empty()).then(|| {
serde_json::json!({
"type": "text",
"text": text,
})
})
}
}
}
fn import_canonical_session(path: &Path) -> Result<ImportedSession> {
let file = File::open(path)
.with_context(|| format!("Failed to open Claude session: {}", path.display()))?;
let reader = BufReader::new(file);
let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import);
let mut events = Vec::new();
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut source_title: Option<String> = None;
let mut created_at: Option<chrono::DateTime<Utc>> = None;
let mut last_active_at: Option<chrono::DateTime<Utc>> = None;
let mut extensions = BTreeMap::new();
for (line_idx, line) in reader.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(value) => value,
Err(error) => {
report.push_issue(MappingIssue {
level: MappingIssueLevel::Warning,
disposition: MappingDisposition::Dropped,
code: "invalid_jsonl_line".to_string(),
message: format!("Failed to parse Claude session line: {}", error),
path: Some(format!("line:{}", line_idx + 1)),
raw: Some(Value::String(line)),
});
continue;
}
};
let timestamp = value
.get("timestamp")
.and_then(parse_timestamp_to_ms)
.and_then(chrono::DateTime::from_timestamp_millis)
.unwrap_or_else(Utc::now);
created_at = created_at.or(Some(timestamp));
last_active_at = Some(timestamp);
session_id = value
.get("sessionId")
.and_then(|v| v.as_str())
.map(str::to_string)
.or(session_id);
project_dir = value
.get("cwd")
.and_then(|v| v.as_str())
.map(str::to_string)
.or(project_dir);
let line_type = value
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if line_type == "custom-title" {
source_title = value
.get("customTitle")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|title| !title.is_empty())
.map(str::to_string)
.or(source_title);
extensions.insert("claude_custom_title".to_string(), value.clone());
}
if let Some(event) = canonical_event_from_claude_line(
line_idx + 1,
line_type,
timestamp,
&value,
&mut report,
) {
events.push(event);
}
}
let fallback_id = path
.file_stem()
.and_then(|stem| stem.to_str())
.map(str::to_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
let source_session_id = session_id.unwrap_or(fallback_id);
Ok(ImportedSession {
session: CanonicalSession {
schema: CanonicalSchema::default(),
identity: SessionIdentity {
canonical_id: source_session_id.clone(),
source_title,
},
provenance: SessionProvenance {
imported_at: Utc::now(),
imported_by: Some("memorph-cli".to_string()),
primary_source: ProviderSessionRef {
provider_id: PROVIDER_ID.to_string(),
session_id: source_session_id,
source_path: Some(path.to_string_lossy().to_string()),
},
aliases: Vec::new(),
},
context: SessionContext {
workspace_dir: project_dir,
created_at,
last_active_at,
tags: Vec::new(),
},
events,
artifacts: Vec::new(),
extensions,
},
report,
})
}
fn canonical_event_from_claude_line(
line_number: usize,
line_type: &str,
timestamp: chrono::DateTime<Utc>,
value: &Value,
report: &mut MappingReport,
) -> Option<SessionEvent> {
let event_id = value
.get("uuid")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| format!("claude:line:{}", line_number));
let parent_id = value
.get("parentUuid")
.and_then(|v| v.as_str())
.map(str::to_string);
let Some(message) = value.get("message") else {
return Some(provider_payload_event(
event_id,
SessionEventKind::Lifecycle,
EventRole::System,
timestamp,
line_type,
value.clone(),
value.clone(),
parent_id,
));
};
let role = claude_event_role(line_type, message, value);
let blocks = claude_event_blocks(message.get("content"), value, line_number, report);
if blocks.is_empty() {
return Some(provider_payload_event(
event_id,
SessionEventKind::Unknown,
role,
timestamp,
line_type,
value.clone(),
value.clone(),
parent_id,
));
}
let kind = claude_event_kind(&blocks);
let model = message
.get("model")
.and_then(|v| v.as_str())
.map(str::to_string);
let usage = message.get("usage").map(|usage| UsageStats {
input_tokens: usage.get("input_tokens").and_then(|v| v.as_u64()),
output_tokens: usage.get("output_tokens").and_then(|v| v.as_u64()),
total_tokens: usage.get("total_tokens").and_then(|v| v.as_u64()),
});
Some(SessionEvent {
id: event_id,
kind,
role,
timestamp,
links: EventLinks {
parent_event_id: parent_id.clone(),
provider_parent_id: parent_id,
turn_index: None,
related_event_ids: Vec::new(),
},
blocks,
metadata: EventMetadata {
source: EventSource {
provider_id: PROVIDER_ID.to_string(),
original_id: value
.get("uuid")
.and_then(|v| v.as_str())
.map(str::to_string),
original_role: message
.get("role")
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| Some(line_type.to_string())),
phase: Some(line_type.to_string()),
},
model,
usage,
fidelity: MappingDisposition::Preserved,
provider_ext: {
let mut ext = BTreeMap::new();
ext.insert("claude_raw_line".to_string(), value.clone());
ext
},
},
})
}
fn claude_event_role(line_type: &str, message: &Value, raw: &Value) -> EventRole {
match line_type {
"assistant" => EventRole::Assistant,
"user" => {
if let Some(Value::Array(items)) = message.get("content") {
let all_tool_results = !items.is_empty()
&& items.iter().all(|item| {
item.get("type").and_then(|v| v.as_str()) == Some("tool_result")
});
if all_tool_results {
return EventRole::Tool;
}
}
EventRole::User
}
_ => match message.get("role").and_then(|v| v.as_str()) {
Some("assistant") => EventRole::Assistant,
Some("user") => {
if raw
.get("message")
.and_then(|m| m.get("content"))
.and_then(|v| v.as_array())
.map(|items| {
!items.is_empty()
&& items.iter().all(|item| {
item.get("type").and_then(|v| v.as_str()) == Some("tool_result")
})
})
.unwrap_or(false)
{
EventRole::Tool
} else {
EventRole::User
}
}
Some("system") => EventRole::System,
_ => EventRole::Unknown,
},
}
}
fn claude_event_blocks(
content: Option<&Value>,
raw_line: &Value,
line_number: usize,
report: &mut MappingReport,
) -> Vec<EventBlock> {
match content {
Some(Value::String(text)) => vec![EventBlock::Text { text: text.clone() }],
Some(Value::Array(items)) => items
.iter()
.enumerate()
.map(|(idx, item)| claude_content_block(item, raw_line, line_number, idx, report))
.collect(),
Some(other) => vec![EventBlock::Unknown { raw: other.clone() }],
None => Vec::new(),
}
}
fn claude_content_block(
value: &Value,
raw_line: &Value,
line_number: usize,
block_index: usize,
report: &mut MappingReport,
) -> EventBlock {
match value.get("type").and_then(|v| v.as_str()) {
Some("text") => EventBlock::Text {
text: value
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
},
Some("thinking") => EventBlock::Thinking {
text: value
.get("thinking")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
signature: value
.get("signature")
.and_then(|v| v.as_str())
.map(str::to_string),
},
Some("tool_use") => EventBlock::ToolCall {
tool_call_id: value
.get("id")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| format!("claude-tool-{}-{}", line_number, block_index)),
name: value
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
input: value.get("input").cloned(),
},
Some("tool_result") => EventBlock::ToolResult {
tool_call_id: value
.get("tool_use_id")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| format!("claude-tool-{}-{}", line_number, block_index)),
content: value
.get("content")
.map(|v| {
v.as_str()
.map(str::to_string)
.unwrap_or_else(|| v.to_string())
})
.unwrap_or_default(),
is_error: value
.get("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false),
},
Some(kind) => {
report.push_issue(MappingIssue {
level: MappingIssueLevel::Info,
disposition: MappingDisposition::Preserved,
code: "provider_block_preserved".to_string(),
message: format!("Preserved unsupported Claude content block '{}'", kind),
path: Some(format!("line:{}:block:{}", line_number, block_index)),
raw: Some(raw_line.clone()),
});
EventBlock::ProviderPayload {
kind: kind.to_string(),
payload: value.clone(),
}
}
None => EventBlock::Unknown { raw: value.clone() },
}
}
fn claude_event_kind(blocks: &[EventBlock]) -> SessionEventKind {
if blocks
.iter()
.any(|block| matches!(block, EventBlock::ToolResult { .. }))
{
SessionEventKind::ToolResult
} else if blocks
.iter()
.any(|block| matches!(block, EventBlock::ToolCall { .. }))
{
SessionEventKind::ToolCall
} else if blocks.iter().all(|block| {
matches!(
block,
EventBlock::ProviderPayload { .. } | EventBlock::Unknown { .. }
)
}) {
SessionEventKind::Unknown
} else {
SessionEventKind::Message
}
}
fn provider_payload_event(
id: String,
kind: SessionEventKind,
role: EventRole,
timestamp: chrono::DateTime<Utc>,
payload_kind: &str,
payload: Value,
raw_line: Value,
parent_id: Option<String>,
) -> SessionEvent {
SessionEvent {
id,
kind,
role,
timestamp,
links: EventLinks {
parent_event_id: parent_id.clone(),
provider_parent_id: parent_id,
turn_index: None,
related_event_ids: Vec::new(),
},
blocks: vec![EventBlock::ProviderPayload {
kind: payload_kind.to_string(),
payload,
}],
metadata: EventMetadata {
source: EventSource {
provider_id: PROVIDER_ID.to_string(),
original_id: None,
original_role: Some(payload_kind.to_string()),
phase: Some(payload_kind.to_string()),
},
model: None,
usage: None,
fidelity: MappingDisposition::Preserved,
provider_ext: {
let mut ext = BTreeMap::new();
ext.insert("claude_raw_line".to_string(), raw_line);
ext
},
},
}
}
fn parse_session(path: &Path) -> Option<ProviderSessionSummary> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().filter_map(Result::ok).collect();
let head = lines.iter().take(20).collect::<Vec<_>>();
let tail = lines.iter().rev().take(30).collect::<Vec<_>>();
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut first_user_message: Option<String> = None;
let mut custom_title: Option<String> = None;
for line in &head {
let value: Value = serde_json::from_str(line).ok()?;
if custom_title.is_none()
&& value.get("type").and_then(|v| v.as_str()) == Some("custom-title")
{
custom_title = value
.get("customTitle")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
}
if session_id.is_none() {
session_id = value
.get("sessionId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if project_dir.is_none() {
project_dir = value
.get("cwd")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if created_at.is_none() {
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
if first_user_message.is_none() {
let is_user = value.get("type").and_then(|v| v.as_str()) == Some("user")
|| value
.get("message")
.and_then(|m| m.get("role"))
.and_then(|v| v.as_str())
== Some("user");
if is_user {
if let Some(message) = value.get("message") {
let text = extract_text(message.get("content")?);
let trimmed = text.trim();
if !trimmed.is_empty()
&& !trimmed.contains("<local-command-caveat>")
&& !trimmed.starts_with("<command-name>")
{
first_user_message = Some(trimmed.to_string());
}
}
}
}
if session_id.is_some()
&& project_dir.is_some()
&& created_at.is_some()
&& first_user_message.is_some()
{
break;
}
}
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
for line in &tail {
let value: Value = serde_json::from_str(line).ok()?;
if last_active_at.is_none() {
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
if custom_title.is_none()
&& value.get("type").and_then(|v| v.as_str()) == Some("custom-title")
{
custom_title = value
.get("customTitle")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
}
if summary.is_none() {
if value.get("isMeta").and_then(|v| v.as_bool()) == Some(true) {
continue;
}
if let Some(message) = value.get("message") {
let text = extract_text(message.get("content")?);
if !text.trim().is_empty() {
summary = Some(text);
}
}
}
if last_active_at.is_some() && summary.is_some() && custom_title.is_some() {
break;
}
}
let session_id = session_id.or_else(|| {
path.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.to_string())
})?;
let title = custom_title
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
.or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
.or_else(|| {
project_dir
.as_deref()
.and_then(path_basename)
.map(|v| v.to_string())
});
let _summary = summary.map(|text| truncate_summary(&text, 160));
Some(ProviderSessionSummary {
session_id: session_id.clone(),
title,
project_dir,
last_active_at,
source_path: Some(path.to_string_lossy().to_string()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn import_canonical_session_preserves_claude_structured_and_meta_lines() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
"{}",
serde_json::json!({
"type": "permission-mode",
"permissionMode": "bypassPermissions",
"sessionId": "session-1",
"timestamp": "2026-01-01T00:00:00Z"
})
)
.unwrap();
writeln!(
file,
"{}",
serde_json::json!({
"type": "custom-title",
"customTitle": "Claude Title",
"sessionId": "session-1",
"timestamp": "2026-01-01T00:00:01Z"
})
)
.unwrap();
writeln!(
file,
"{}",
serde_json::json!({
"type": "user",
"uuid": "user-1",
"sessionId": "session-1",
"cwd": "/tmp/project",
"timestamp": "2026-01-01T00:00:02Z",
"message": {
"role": "user",
"content": "Build this"
}
})
)
.unwrap();
writeln!(
file,
"{}",
serde_json::json!({
"type": "assistant",
"uuid": "assistant-1",
"parentUuid": "user-1",
"sessionId": "session-1",
"cwd": "/tmp/project",
"timestamp": "2026-01-01T00:00:03Z",
"message": {
"role": "assistant",
"model": "claude-sonnet",
"usage": {
"input_tokens": 10,
"output_tokens": 20
},
"content": [
{
"type": "thinking",
"thinking": "Thinking",
"signature": "sig"
},
{
"type": "tool_use",
"id": "toolu_1",
"name": "Read",
"input": { "file_path": "Cargo.toml" }
}
]
}
})
)
.unwrap();
writeln!(
file,
"{}",
serde_json::json!({
"type": "user",
"uuid": "tool-result-1",
"parentUuid": "assistant-1",
"sessionId": "session-1",
"cwd": "/tmp/project",
"timestamp": "2026-01-01T00:00:04Z",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "contents",
"is_error": false
}
]
}
})
)
.unwrap();
writeln!(
file,
"{}",
serde_json::json!({
"type": "file-history-snapshot",
"sessionId": "session-1",
"timestamp": "2026-01-01T00:00:05Z",
"files": [{ "path": "Cargo.toml" }]
})
)
.unwrap();
let imported = import_canonical_session(file.path()).unwrap();
assert_eq!(imported.session.identity.canonical_id, "session-1");
assert_eq!(
imported.session.identity.source_title.as_deref(),
Some("Claude Title")
);
assert_eq!(
imported.session.context.workspace_dir.as_deref(),
Some("/tmp/project")
);
assert!(imported.session.events.iter().any(|event| {
event.kind == SessionEventKind::Lifecycle
&& matches!(
event.blocks.first(),
Some(EventBlock::ProviderPayload { kind, .. })
if kind == "file-history-snapshot"
)
}));
let assistant = imported
.session
.events
.iter()
.find(|event| event.id == "assistant-1")
.unwrap();
assert_eq!(assistant.kind, SessionEventKind::ToolCall);
assert!(matches!(
assistant.blocks.first(),
Some(EventBlock::Thinking {
text,
signature: Some(signature)
}) if text == "Thinking" && signature == "sig"
));
assert!(assistant.blocks.iter().any(|block| matches!(
block,
EventBlock::ToolCall {
tool_call_id,
name,
..
} if tool_call_id == "toolu_1" && name == "Read"
)));
let tool_result = imported
.session
.events
.iter()
.find(|event| event.id == "tool-result-1")
.unwrap();
assert_eq!(tool_result.role, EventRole::Tool);
assert!(matches!(
tool_result.blocks.first(),
Some(EventBlock::ToolResult {
tool_call_id,
content,
is_error
}) if tool_call_id == "toolu_1" && content == "contents" && !is_error
));
}
}