use super::{SessionMessage, SessionProvider, SessionSummary, ShareError, SharedSession, MessageRole};
use serde_json::Value;
use std::path::{Path, PathBuf};
#[derive(Default)]
pub struct ClaudeCodeProvider {
root_override: Option<PathBuf>,
}
impl ClaudeCodeProvider {
pub fn with_root(root: PathBuf) -> Self {
Self { root_override: Some(root) }
}
fn root(&self) -> Option<PathBuf> {
self.root_override
.clone()
.or_else(|| dirs::home_dir().map(|h| h.join(".claude").join("projects")))
}
}
impl SessionProvider for ClaudeCodeProvider {
fn name(&self) -> &str {
"claude-code"
}
fn list_sessions(&self) -> Result<Vec<SessionSummary>, ShareError> {
let root = match self.root() {
Some(r) if r.is_dir() => r,
_ => return Ok(Vec::new()), };
let mut out = Vec::new();
for project_dir in std::fs::read_dir(&root)?.filter_map(|e| e.ok()) {
let project_path = project_dir.path();
if !project_path.is_dir() {
continue;
}
let decoded_cwd = decode_project_dir(&project_path);
for entry in std::fs::read_dir(&project_path)?.filter_map(|e| e.ok()) {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let id = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if id.is_empty() {
continue;
}
if let Some(summary) = summarize_file(&p, &id, decoded_cwd.as_deref()) {
out.push(summary);
}
}
}
Ok(out)
}
fn load_session(&self, id: &str) -> Result<SharedSession, ShareError> {
let root = self
.root()
.ok_or_else(|| ShareError::NotFound(id.to_string()))?;
if !root.is_dir() {
return Err(ShareError::NotFound(id.to_string()));
}
for project_dir in std::fs::read_dir(&root)?.filter_map(|e| e.ok()) {
let project_path = project_dir.path();
if !project_path.is_dir() {
continue;
}
let candidate = project_path.join(format!("{}.jsonl", id));
if candidate.exists() {
return parse_jsonl(&candidate, id, decode_project_dir(&project_path));
}
}
Err(ShareError::NotFound(id.to_string()))
}
}
fn decode_project_dir(p: &Path) -> Option<PathBuf> {
let name = p.file_name()?.to_str()?;
if name.is_empty() {
return None;
}
let decoded = name.replace('-', "/");
Some(PathBuf::from(decoded))
}
fn summarize_file(path: &Path, id: &str, cwd: Option<&Path>) -> Option<SessionSummary> {
let content = std::fs::read_to_string(path).ok()?;
let mut started_at: Option<chrono::DateTime<chrono::Utc>> = None;
let mut message_count = 0usize;
let mut title_hint: Option<String> = None;
for line in content.lines() {
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
if !is_message_type(ty) {
continue;
}
message_count += 1;
if started_at.is_none() {
started_at = v
.get("timestamp")
.and_then(|t| t.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.with_timezone(&chrono::Utc));
}
if title_hint.is_none() && ty == "user" {
if let Some(text) = first_user_text(&v) {
let trimmed: String = text.chars().take(80).collect();
title_hint = Some(trimmed);
}
}
}
Some(SessionSummary {
provider: "claude-code".to_string(),
id: id.to_string(),
project_path: cwd.map(|p| p.to_path_buf()),
started_at,
message_count,
title_hint,
imported: false,
})
}
fn parse_jsonl(path: &Path, id: &str, cwd: Option<PathBuf>) -> Result<SharedSession, ShareError> {
let content = std::fs::read_to_string(path)?;
let mut messages: Vec<SessionMessage> = Vec::new();
let mut started_at: Option<chrono::DateTime<chrono::Utc>> = None;
for line in content.lines() {
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue, };
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
if !is_message_type(ty) {
continue;
}
let timestamp = v
.get("timestamp")
.and_then(|t| t.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.with_timezone(&chrono::Utc));
if started_at.is_none() {
started_at = timestamp;
}
if let Some(msg) = decode_envelope(&v) {
messages.push(SessionMessage {
role: msg.role,
content: msg.content,
timestamp,
metadata: msg.metadata,
});
for extra in msg.extras {
messages.push(SessionMessage {
role: extra.role,
content: extra.content,
timestamp,
metadata: extra.metadata,
});
}
}
}
Ok(SharedSession {
provider: "claude-code".to_string(),
id: id.to_string(),
project_path: cwd,
started_at,
messages,
})
}
fn is_message_type(ty: &str) -> bool {
matches!(ty, "user" | "assistant")
}
struct Decoded {
role: MessageRole,
content: String,
metadata: std::collections::HashMap<String, String>,
extras: Vec<DecodedExtra>,
}
struct DecodedExtra {
role: MessageRole,
content: String,
metadata: std::collections::HashMap<String, String>,
}
fn decode_envelope(v: &Value) -> Option<Decoded> {
let ty = v.get("type").and_then(|t| t.as_str())?;
let message = v.get("message")?;
let mut metadata = std::collections::HashMap::new();
if let Some(model) = message.get("model").and_then(|m| m.as_str()) {
metadata.insert("model".to_string(), model.to_string());
}
match ty {
"user" => {
let content_val = message.get("content")?;
if let Some(s) = content_val.as_str() {
return Some(Decoded {
role: MessageRole::User,
content: s.to_string(),
metadata,
extras: Vec::new(),
});
}
if let Some(arr) = content_val.as_array() {
let mut texts: Vec<String> = Vec::new();
let mut role = MessageRole::User;
for block in arr {
let bt = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match bt {
"tool_result" => {
role = MessageRole::ToolResult;
if let Some(tu_id) = block.get("tool_use_id").and_then(|t| t.as_str())
{
metadata.insert("tool_use_id".to_string(), tu_id.to_string());
}
if let Some(c) = block.get("content") {
texts.push(stringify_content(c));
}
}
"text" => {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
texts.push(t.to_string());
}
}
_ => {}
}
}
if !texts.is_empty() {
return Some(Decoded {
role,
content: texts.join("\n"),
metadata,
extras: Vec::new(),
});
}
}
None
}
"assistant" => {
let content_val = message.get("content")?;
let arr = content_val.as_array()?;
let mut primary_text: Vec<String> = Vec::new();
let mut extras: Vec<DecodedExtra> = Vec::new();
for block in arr {
let bt = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match bt {
"text" => {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
primary_text.push(t.to_string());
}
}
"thinking" => {
if let Some(t) = block.get("thinking").and_then(|t| t.as_str()) {
primary_text.push(format!("_(thinking)_ {}", t));
}
}
"tool_use" => {
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("?");
let input = block
.get("input")
.map(|i| serde_json::to_string(i).unwrap_or_default())
.unwrap_or_default();
let mut tu_meta = std::collections::HashMap::new();
tu_meta.insert("tool_name".to_string(), name.to_string());
if let Some(id) = block.get("id").and_then(|i| i.as_str()) {
tu_meta.insert("tool_use_id".to_string(), id.to_string());
}
extras.push(DecodedExtra {
role: MessageRole::ToolUse,
content: format!("{}({})", name, input),
metadata: tu_meta,
});
}
_ => {} }
}
Some(Decoded {
role: MessageRole::Assistant,
content: primary_text.join("\n\n"),
metadata,
extras,
})
}
_ => None,
}
}
fn first_user_text(v: &Value) -> Option<String> {
let m = v.get("message")?;
if let Some(s) = m.get("content").and_then(|c| c.as_str()) {
return Some(s.to_string());
}
for block in m.get("content").and_then(|c| c.as_array())? {
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
return Some(t.to_string());
}
}
}
None
}
fn stringify_content(v: &Value) -> String {
if let Some(s) = v.as_str() {
return s.to_string();
}
if let Some(arr) = v.as_array() {
let mut parts = Vec::new();
for block in arr {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
parts.push(t.to_string());
} else if let Some(t) = block.get("content").and_then(|c| c.as_str()) {
parts.push(t.to_string());
}
}
return parts.join("\n");
}
serde_json::to_string(v).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn write_session(dir: &Path, id: &str, content: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(format!("{}.jsonl", id)), content).unwrap();
}
#[test]
fn lists_sessions_under_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let project_dir = root.join("-Users-foo-myproject");
write_session(
&project_dir,
"abc-123",
r#"{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-06T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","model":"claude","content":[{"type":"text","text":"hello"}]},"timestamp":"2026-05-06T10:00:01Z"}
"#,
);
let p = ClaudeCodeProvider::with_root(root);
let sessions = p.list_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "abc-123");
assert_eq!(sessions[0].provider, "claude-code");
assert_eq!(sessions[0].message_count, 2);
assert_eq!(sessions[0].title_hint.as_deref(), Some("hi"));
}
#[test]
fn loads_user_and_assistant_messages() {
let tmp = tempfile::tempdir().unwrap();
let project_dir = tmp.path().join("-Users-foo-myproject");
write_session(
&project_dir,
"s1",
r#"{"type":"user","message":{"role":"user","content":"compute pi"},"timestamp":"2026-05-06T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","model":"claude-opus-4-7","content":[{"type":"thinking","thinking":"approach: leibniz"},{"type":"text","text":"Sure, here goes"},{"type":"tool_use","id":"tool_1","name":"Bash","input":{"command":"echo hi"}}]},"timestamp":"2026-05-06T10:00:01Z"}
"#,
);
let p = ClaudeCodeProvider::with_root(tmp.path().to_path_buf());
let s = p.load_session("s1").unwrap();
assert_eq!(s.messages.len(), 3);
assert!(matches!(s.messages[0].role, MessageRole::User));
assert!(matches!(s.messages[1].role, MessageRole::Assistant));
assert!(matches!(s.messages[2].role, MessageRole::ToolUse));
assert!(s.messages[1].content.contains("Sure, here goes"));
assert!(s.messages[1].content.contains("(thinking)"));
assert_eq!(
s.messages[1].metadata.get("model").map(|s| s.as_str()),
Some("claude-opus-4-7")
);
assert!(s.messages[2].content.contains("Bash"));
assert_eq!(
s.messages[2].metadata.get("tool_use_id").map(|s| s.as_str()),
Some("tool_1")
);
}
#[test]
fn skips_non_message_types() {
let tmp = tempfile::tempdir().unwrap();
let project_dir = tmp.path().join("-x");
write_session(
&project_dir,
"s2",
r#"{"type":"queue-operation","operation":"enqueue"}
{"type":"system","whatever":true}
{"type":"user","message":{"role":"user","content":"only one"},"timestamp":"2026-05-06T10:00:00Z"}
"#,
);
let p = ClaudeCodeProvider::with_root(tmp.path().to_path_buf());
let s = p.load_session("s2").unwrap();
assert_eq!(s.messages.len(), 1);
}
#[test]
fn unknown_session_returns_not_found() {
let tmp = tempfile::tempdir().unwrap();
let p = ClaudeCodeProvider::with_root(tmp.path().to_path_buf());
let err = p.load_session("nope").unwrap_err();
assert!(matches!(err, ShareError::NotFound(_)));
}
#[test]
fn list_on_missing_root_is_empty_not_error() {
let p = ClaudeCodeProvider::with_root(PathBuf::from("/tmp/definitely-does-not-exist-iself"));
assert!(p.list_sessions().unwrap().is_empty());
}
}