use super::{
clean_path, dedup_paths, ok_or_flag, parse_ts, redacted_truncate, title_from_messages, Adapter,
Discovered,
};
use crate::model::{Message, Role, Session};
use crate::util::short_id;
use anyhow::Result;
use serde_json::Value;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Default)]
pub struct ClaudeCode {
root: Option<PathBuf>,
}
impl ClaudeCode {
pub fn in_home(home: impl Into<PathBuf>) -> Self {
ClaudeCode {
root: Some(home.into().join("projects")),
}
}
}
impl Adapter for ClaudeCode {
fn name(&self) -> &'static str {
"claude-code"
}
fn root(&self) -> Option<PathBuf> {
match &self.root {
Some(root) => Some(root.clone()),
None => Some(dirs::home_dir()?.join(".claude").join("projects")),
}
}
fn reconcile_scope(&self) -> Option<String> {
super::root_scope(self.root.as_deref())
}
fn discover(&self) -> Discovered {
let Some(root) = self.root() else {
return Vec::new().into();
};
if !root.exists() {
return Vec::new().into(); }
let mut had_error = false;
let files = WalkDir::new(root)
.into_iter()
.filter_map(|e| ok_or_flag(e, &mut had_error))
.filter(|e| e.file_type().is_file())
.filter(|e| e.path().extension().is_some_and(|x| x == "jsonl"))
.map(|e| e.into_path())
.collect();
Discovered { files, had_error }
}
fn parse(&self, path: &Path) -> Result<Session> {
let (lines, windowed) = crate::util::session_lines(path)?;
let mut messages: Vec<Message> = Vec::new();
let mut touched: Vec<String> = Vec::new();
let mut edits: Vec<crate::model::EditEvent> = Vec::new();
let mut cwd: Option<String> = None;
let mut summary: Option<String> = None;
let mut ai_title: Option<String> = None;
let mut started = None;
let mut ended = None;
for line in &lines {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if cwd.is_none() {
if let Some(c) = v.get("cwd").and_then(Value::as_str) {
cwd = Some(c.to_string());
}
}
if let Some(ts) = v
.get("timestamp")
.and_then(Value::as_str)
.and_then(parse_ts)
{
if started.is_none() {
started = Some(ts);
}
ended = Some(ts);
}
match v.get("type").and_then(Value::as_str) {
Some("summary") => {
if summary.is_none() {
summary = v.get("summary").and_then(Value::as_str).map(String::from);
}
}
Some("ai-title") => {
if ai_title.is_none() {
ai_title = v.get("aiTitle").and_then(Value::as_str).map(String::from);
}
}
Some("user") => {
if v.get("isMeta").and_then(Value::as_bool) == Some(true) {
continue;
}
let ts = v
.get("timestamp")
.and_then(Value::as_str)
.and_then(parse_ts);
let Some(content) = v.pointer("/message/content") else {
continue;
};
match content {
Value::String(s) => push(&mut messages, Role::User, s, ts),
Value::Array(blocks) => {
for b in blocks {
match b.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(t) = b.get("text").and_then(Value::as_str) {
push(&mut messages, Role::User, t, ts);
}
}
Some("tool_result") => {
let t = block_text(b.get("content"));
if !t.is_empty() {
push(
&mut messages,
Role::Tool,
&redacted_truncate(&t, 500),
ts,
);
}
}
_ => {}
}
}
}
_ => {}
}
}
Some("assistant") => {
let ts = v
.get("timestamp")
.and_then(Value::as_str)
.and_then(parse_ts);
let Some(Value::Array(blocks)) = v.pointer("/message/content") else {
continue;
};
for b in blocks {
match b.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(t) = b.get("text").and_then(Value::as_str) {
push(&mut messages, Role::Assistant, t, ts);
}
}
Some("tool_use") => {
let name = b.get("name").and_then(Value::as_str).unwrap_or("?");
if let Some(ev) = edit_event(name, b.get("input"), ts) {
touched.push(ev.path.clone());
edits.push(ev);
}
let input =
b.get("input").map(|i| i.to_string()).unwrap_or_default();
let text = format!("{name} {}", redacted_truncate(&input, 300));
push(&mut messages, Role::Tool, &text, ts);
}
_ => {}
}
}
}
_ => {}
}
}
let project = cwd.unwrap_or_else(|| {
path.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
});
let base_title = summary
.or(ai_title)
.map(|s| redacted_truncate(&s, 80))
.unwrap_or_else(|| title_from_messages(&messages));
let title = if windowed {
format!("[large] {base_title}")
} else {
base_title
};
let subagent = path.to_string_lossy().contains("/subagents/");
Ok(Session {
id: short_id(&path.to_string_lossy()),
tool: self.name(),
path: path.to_path_buf(),
project,
started,
ended,
title,
subagent,
messages,
touched: dedup_paths(touched),
edits,
})
}
}
fn edited_path(name: &str, input: Option<&Value>) -> Option<String> {
let writes = matches!(
name,
"Edit" | "Write" | "MultiEdit" | "NotebookEdit" | "str_replace_based_edit_tool"
);
if !writes {
return None;
}
let input = input?;
for key in ["file_path", "notebook_path", "path"] {
if let Some(p) = input.get(key).and_then(Value::as_str) {
if !p.is_empty() {
return Some(p.to_string());
}
}
}
None
}
const SNIPPET_CAP: usize = 200;
fn edit_event(
name: &str,
input: Option<&Value>,
ts: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<crate::model::EditEvent> {
use crate::model::{EditEvent, EditKind};
let path = clean_path(&edited_path(name, input)?)?;
let input = input?;
let kind = match name {
"Edit" | "str_replace_based_edit_tool" => EditKind::Edit,
"Write" => EditKind::Write,
"MultiEdit" => EditKind::MultiEdit,
"NotebookEdit" => EditKind::NotebookEdit,
_ => return None,
};
let raw = match kind {
EditKind::Edit => str_field(input, &["new_string", "new_str", "file_text"]),
EditKind::Write => str_field(input, &["content"]),
EditKind::MultiEdit => input
.get("edits")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(|e| e.get("new_string").and_then(Value::as_str))
.unwrap_or(""),
EditKind::NotebookEdit => str_field(input, &["new_source"]),
};
let snippet = redacted_truncate(raw.trim(), SNIPPET_CAP);
Some(EditEvent {
path,
kind,
snippet,
ts,
})
}
fn str_field<'a>(v: &'a Value, keys: &[&str]) -> &'a str {
keys.iter()
.find_map(|k| v.get(k).and_then(Value::as_str))
.unwrap_or("")
}
fn push(
messages: &mut Vec<Message>,
role: Role,
text: &str,
ts: Option<chrono::DateTime<chrono::Utc>>,
) {
let text = text.trim();
if !text.is_empty() {
messages.push(Message {
role,
text: text.to_string(),
ts,
});
}
}
fn block_text(content: Option<&Value>) -> String {
match content {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(blocks)) => blocks
.iter()
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join(" "),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::EditKind;
use serde_json::json;
#[test]
fn edit_event_captures_kind_and_new_string_snippet() {
let input = json!({
"file_path": "/repo/src/auth.rs",
"old_string": "let x = 1;",
"new_string": "let x = 2; // fixed",
});
let ev = edit_event("Edit", Some(&input), None).expect("Edit yields an edit event");
assert_eq!(ev.path, "/repo/src/auth.rs");
assert_eq!(ev.kind, EditKind::Edit);
assert!(
ev.snippet.contains("let x = 2;"),
"snippet should show the new code, got: {:?}",
ev.snippet
);
}
#[test]
fn write_event_snippet_comes_from_content() {
let input = json!({ "file_path": "/repo/new.rs", "content": "fn main() {}\n" });
let ev = edit_event("Write", Some(&input), None).expect("Write yields an edit event");
assert_eq!(ev.kind, EditKind::Write);
assert!(ev.snippet.contains("fn main()"), "got: {:?}", ev.snippet);
}
#[test]
fn multiedit_event_snippet_comes_from_first_edit() {
let input = json!({
"file_path": "/repo/a.rs",
"edits": [
{ "old_string": "a", "new_string": "alpha" },
{ "old_string": "b", "new_string": "beta" },
],
});
let ev =
edit_event("MultiEdit", Some(&input), None).expect("MultiEdit yields an edit event");
assert_eq!(ev.kind, EditKind::MultiEdit);
assert!(ev.snippet.contains("alpha"), "got: {:?}", ev.snippet);
}
#[test]
fn non_write_tools_are_not_edit_events() {
let input = json!({ "command": "ls", "description": "list" });
assert!(edit_event("Bash", Some(&input), None).is_none());
assert!(edit_event("Read", Some(&json!({ "file_path": "/x" })), None).is_none());
}
#[test]
fn edit_event_cleans_the_path_like_touched() {
let ev = edit_event(
"Edit",
Some(&json!({ "file_path": " /repo/a.rs ", "new_string": "x" })),
None,
)
.expect("a trimmable path still yields an edit");
assert_eq!(ev.path, "/repo/a.rs");
assert!(edit_event(
"Edit",
Some(&json!({ "file_path": "/re\npo/a.rs", "new_string": "x" })),
None
)
.is_none());
}
#[test]
fn parse_populates_edits_alongside_touched() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sess.jsonl");
let line = r#"{"type":"assistant","timestamp":"2026-07-01T10:00:00Z","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/auth.rs","old_string":"a","new_string":"let fixed = true;"}}]}}"#;
std::fs::write(&path, format!("{line}\n")).unwrap();
let session = ClaudeCode::default().parse(&path).unwrap();
assert_eq!(session.edits.len(), 1, "one edit event extracted");
assert_eq!(session.edits[0].path, "/repo/src/auth.rs");
assert_eq!(session.edits[0].kind, EditKind::Edit);
assert!(session.edits[0].snippet.contains("let fixed = true;"));
assert_eq!(session.touched, vec!["/repo/src/auth.rs".to_string()]);
}
#[test]
fn in_home_discovers_that_installs_sessions_and_scopes_reconciliation() {
let home = tempfile::tempdir().unwrap();
let project = home.path().join("projects").join("-repo");
std::fs::create_dir_all(&project).unwrap();
let file = project.join("11111111-2222-3333-4444-555555555555.jsonl");
std::fs::write(&file, "{\"type\":\"user\",\"cwd\":\"/repo\"}\n").unwrap();
let adapter = ClaudeCode::in_home(home.path());
let found = adapter.discover();
assert!(!found.had_error);
assert_eq!(found.files, vec![file.clone()]);
let scope = adapter
.reconcile_scope()
.expect("an explicit install is scoped");
assert!(
file.to_string_lossy().starts_with(&scope),
"{scope} must be a prefix of the discovered {}",
file.display()
);
assert_eq!(
ClaudeCode::default().reconcile_scope(),
None,
"the stock adapter still speaks for every claude-code row"
);
}
}