1use super::{
2 clean_path, dedup_paths, ok_or_flag, parse_ts, redacted_truncate, title_from_messages, Adapter,
3 Discovered,
4};
5use crate::model::{Message, Role, Session};
6use crate::util::short_id;
7use anyhow::Result;
8use serde_json::Value;
9use std::path::{Path, PathBuf};
10use walkdir::WalkDir;
11
12#[derive(Default)]
21pub struct ClaudeCode {
22 root: Option<PathBuf>,
24}
25
26impl ClaudeCode {
27 pub fn in_home(home: impl Into<PathBuf>) -> Self {
31 ClaudeCode {
32 root: Some(home.into().join("projects")),
33 }
34 }
35}
36
37impl Adapter for ClaudeCode {
38 fn name(&self) -> &'static str {
39 "claude-code"
40 }
41
42 fn root(&self) -> Option<PathBuf> {
43 match &self.root {
44 Some(root) => Some(root.clone()),
45 None => Some(dirs::home_dir()?.join(".claude").join("projects")),
46 }
47 }
48
49 fn reconcile_scope(&self) -> Option<String> {
52 super::root_scope(self.root.as_deref())
53 }
54
55 fn discover(&self) -> Discovered {
56 let Some(root) = self.root() else {
60 return Vec::new().into();
61 };
62 if !root.exists() {
63 return Vec::new().into(); }
65 let mut had_error = false;
66 let files = WalkDir::new(root)
67 .into_iter()
68 .filter_map(|e| ok_or_flag(e, &mut had_error))
69 .filter(|e| e.file_type().is_file())
70 .filter(|e| e.path().extension().is_some_and(|x| x == "jsonl"))
71 .map(|e| e.into_path())
72 .collect();
73 Discovered { files, had_error }
74 }
75
76 fn parse(&self, path: &Path) -> Result<Session> {
77 let (lines, windowed) = crate::util::session_lines(path)?;
81
82 let mut messages: Vec<Message> = Vec::new();
83 let mut touched: Vec<String> = Vec::new();
84 let mut edits: Vec<crate::model::EditEvent> = Vec::new();
85 let mut cwd: Option<String> = None;
86 let mut summary: Option<String> = None;
87 let mut ai_title: Option<String> = None;
88 let mut started = None;
89 let mut ended = None;
90
91 for line in &lines {
92 let Ok(v) = serde_json::from_str::<Value>(line) else {
93 continue;
94 };
95
96 if cwd.is_none() {
97 if let Some(c) = v.get("cwd").and_then(Value::as_str) {
98 cwd = Some(c.to_string());
99 }
100 }
101 if let Some(ts) = v
102 .get("timestamp")
103 .and_then(Value::as_str)
104 .and_then(parse_ts)
105 {
106 if started.is_none() {
107 started = Some(ts);
108 }
109 ended = Some(ts);
110 }
111
112 match v.get("type").and_then(Value::as_str) {
113 Some("summary") => {
114 if summary.is_none() {
115 summary = v.get("summary").and_then(Value::as_str).map(String::from);
116 }
117 }
118 Some("ai-title") => {
119 if ai_title.is_none() {
120 ai_title = v.get("aiTitle").and_then(Value::as_str).map(String::from);
121 }
122 }
123 Some("user") => {
124 if v.get("isMeta").and_then(Value::as_bool) == Some(true) {
126 continue;
127 }
128 let ts = v
129 .get("timestamp")
130 .and_then(Value::as_str)
131 .and_then(parse_ts);
132 let Some(content) = v.pointer("/message/content") else {
133 continue;
134 };
135 match content {
136 Value::String(s) => push(&mut messages, Role::User, s, ts),
137 Value::Array(blocks) => {
138 for b in blocks {
139 match b.get("type").and_then(Value::as_str) {
140 Some("text") => {
141 if let Some(t) = b.get("text").and_then(Value::as_str) {
142 push(&mut messages, Role::User, t, ts);
143 }
144 }
145 Some("tool_result") => {
146 let t = block_text(b.get("content"));
147 if !t.is_empty() {
148 push(
149 &mut messages,
150 Role::Tool,
151 &redacted_truncate(&t, 500),
152 ts,
153 );
154 }
155 }
156 _ => {}
157 }
158 }
159 }
160 _ => {}
161 }
162 }
163 Some("assistant") => {
164 let ts = v
165 .get("timestamp")
166 .and_then(Value::as_str)
167 .and_then(parse_ts);
168 let Some(Value::Array(blocks)) = v.pointer("/message/content") else {
169 continue;
170 };
171 for b in blocks {
172 match b.get("type").and_then(Value::as_str) {
173 Some("text") => {
174 if let Some(t) = b.get("text").and_then(Value::as_str) {
175 push(&mut messages, Role::Assistant, t, ts);
176 }
177 }
178 Some("tool_use") => {
179 let name = b.get("name").and_then(Value::as_str).unwrap_or("?");
180 if let Some(ev) = edit_event(name, b.get("input"), ts) {
181 touched.push(ev.path.clone());
182 edits.push(ev);
183 }
184 let input =
185 b.get("input").map(|i| i.to_string()).unwrap_or_default();
186 let text = format!("{name} {}", redacted_truncate(&input, 300));
187 push(&mut messages, Role::Tool, &text, ts);
188 }
189 _ => {}
190 }
191 }
192 }
193 _ => {}
194 }
195 }
196
197 let project = cwd.unwrap_or_else(|| {
198 path.parent()
200 .and_then(|p| p.file_name())
201 .map(|n| n.to_string_lossy().into_owned())
202 .unwrap_or_default()
203 });
204 let base_title = summary
205 .or(ai_title)
206 .map(|s| redacted_truncate(&s, 80))
207 .unwrap_or_else(|| title_from_messages(&messages));
208 let title = if windowed {
210 format!("[large] {base_title}")
211 } else {
212 base_title
213 };
214 let subagent = path.to_string_lossy().contains("/subagents/");
215
216 Ok(Session {
217 id: short_id(&path.to_string_lossy()),
218 tool: self.name(),
219 path: path.to_path_buf(),
220 project,
221 started,
222 ended,
223 title,
224 subagent,
225 messages,
226 touched: dedup_paths(touched),
227 edits,
228 })
229 }
230}
231
232fn edited_path(name: &str, input: Option<&Value>) -> Option<String> {
237 let writes = matches!(
238 name,
239 "Edit" | "Write" | "MultiEdit" | "NotebookEdit" | "str_replace_based_edit_tool"
240 );
241 if !writes {
242 return None;
243 }
244 let input = input?;
245 for key in ["file_path", "notebook_path", "path"] {
246 if let Some(p) = input.get(key).and_then(Value::as_str) {
247 if !p.is_empty() {
248 return Some(p.to_string());
249 }
250 }
251 }
252 None
253}
254
255const SNIPPET_CAP: usize = 200;
256
257fn edit_event(
261 name: &str,
262 input: Option<&Value>,
263 ts: Option<chrono::DateTime<chrono::Utc>>,
264) -> Option<crate::model::EditEvent> {
265 use crate::model::{EditEvent, EditKind};
266 let path = clean_path(&edited_path(name, input)?)?;
269 let input = input?;
270 let kind = match name {
273 "Edit" | "str_replace_based_edit_tool" => EditKind::Edit,
274 "Write" => EditKind::Write,
275 "MultiEdit" => EditKind::MultiEdit,
276 "NotebookEdit" => EditKind::NotebookEdit,
277 _ => return None,
278 };
279 let raw = match kind {
280 EditKind::Edit => str_field(input, &["new_string", "new_str", "file_text"]),
282 EditKind::Write => str_field(input, &["content"]),
283 EditKind::MultiEdit => input
284 .get("edits")
285 .and_then(Value::as_array)
286 .and_then(|a| a.first())
287 .and_then(|e| e.get("new_string").and_then(Value::as_str))
288 .unwrap_or(""),
289 EditKind::NotebookEdit => str_field(input, &["new_source"]),
290 };
291 let snippet = redacted_truncate(raw.trim(), SNIPPET_CAP);
292 Some(EditEvent {
293 path,
294 kind,
295 snippet,
296 ts,
297 })
298}
299
300fn str_field<'a>(v: &'a Value, keys: &[&str]) -> &'a str {
302 keys.iter()
303 .find_map(|k| v.get(k).and_then(Value::as_str))
304 .unwrap_or("")
305}
306
307fn push(
308 messages: &mut Vec<Message>,
309 role: Role,
310 text: &str,
311 ts: Option<chrono::DateTime<chrono::Utc>>,
312) {
313 let text = text.trim();
314 if !text.is_empty() {
315 messages.push(Message {
316 role,
317 text: text.to_string(),
318 ts,
319 });
320 }
321}
322
323fn block_text(content: Option<&Value>) -> String {
325 match content {
326 Some(Value::String(s)) => s.clone(),
327 Some(Value::Array(blocks)) => blocks
328 .iter()
329 .filter_map(|b| b.get("text").and_then(Value::as_str))
330 .collect::<Vec<_>>()
331 .join(" "),
332 _ => String::new(),
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::model::EditKind;
340 use serde_json::json;
341
342 #[test]
343 fn edit_event_captures_kind_and_new_string_snippet() {
344 let input = json!({
345 "file_path": "/repo/src/auth.rs",
346 "old_string": "let x = 1;",
347 "new_string": "let x = 2; // fixed",
348 });
349 let ev = edit_event("Edit", Some(&input), None).expect("Edit yields an edit event");
350 assert_eq!(ev.path, "/repo/src/auth.rs");
351 assert_eq!(ev.kind, EditKind::Edit);
352 assert!(
353 ev.snippet.contains("let x = 2;"),
354 "snippet should show the new code, got: {:?}",
355 ev.snippet
356 );
357 }
358
359 #[test]
360 fn write_event_snippet_comes_from_content() {
361 let input = json!({ "file_path": "/repo/new.rs", "content": "fn main() {}\n" });
362 let ev = edit_event("Write", Some(&input), None).expect("Write yields an edit event");
363 assert_eq!(ev.kind, EditKind::Write);
364 assert!(ev.snippet.contains("fn main()"), "got: {:?}", ev.snippet);
365 }
366
367 #[test]
368 fn multiedit_event_snippet_comes_from_first_edit() {
369 let input = json!({
370 "file_path": "/repo/a.rs",
371 "edits": [
372 { "old_string": "a", "new_string": "alpha" },
373 { "old_string": "b", "new_string": "beta" },
374 ],
375 });
376 let ev =
377 edit_event("MultiEdit", Some(&input), None).expect("MultiEdit yields an edit event");
378 assert_eq!(ev.kind, EditKind::MultiEdit);
379 assert!(ev.snippet.contains("alpha"), "got: {:?}", ev.snippet);
380 }
381
382 #[test]
383 fn non_write_tools_are_not_edit_events() {
384 let input = json!({ "command": "ls", "description": "list" });
385 assert!(edit_event("Bash", Some(&input), None).is_none());
386 assert!(edit_event("Read", Some(&json!({ "file_path": "/x" })), None).is_none());
387 }
388
389 #[test]
390 fn edit_event_cleans_the_path_like_touched() {
391 let ev = edit_event(
394 "Edit",
395 Some(&json!({ "file_path": " /repo/a.rs ", "new_string": "x" })),
396 None,
397 )
398 .expect("a trimmable path still yields an edit");
399 assert_eq!(ev.path, "/repo/a.rs");
400 assert!(edit_event(
403 "Edit",
404 Some(&json!({ "file_path": "/re\npo/a.rs", "new_string": "x" })),
405 None
406 )
407 .is_none());
408 }
409
410 #[test]
411 fn parse_populates_edits_alongside_touched() {
412 let dir = tempfile::tempdir().unwrap();
413 let path = dir.path().join("sess.jsonl");
414 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;"}}]}}"#;
415 std::fs::write(&path, format!("{line}\n")).unwrap();
416
417 let session = ClaudeCode::default().parse(&path).unwrap();
418
419 assert_eq!(session.edits.len(), 1, "one edit event extracted");
420 assert_eq!(session.edits[0].path, "/repo/src/auth.rs");
421 assert_eq!(session.edits[0].kind, EditKind::Edit);
422 assert!(session.edits[0].snippet.contains("let fixed = true;"));
423 assert_eq!(session.touched, vec!["/repo/src/auth.rs".to_string()]);
425 }
426
427 #[test]
431 fn in_home_discovers_that_installs_sessions_and_scopes_reconciliation() {
432 let home = tempfile::tempdir().unwrap();
433 let project = home.path().join("projects").join("-repo");
434 std::fs::create_dir_all(&project).unwrap();
435 let file = project.join("11111111-2222-3333-4444-555555555555.jsonl");
436 std::fs::write(&file, "{\"type\":\"user\",\"cwd\":\"/repo\"}\n").unwrap();
437
438 let adapter = ClaudeCode::in_home(home.path());
439 let found = adapter.discover();
440 assert!(!found.had_error);
441 assert_eq!(found.files, vec![file.clone()]);
442
443 let scope = adapter
444 .reconcile_scope()
445 .expect("an explicit install is scoped");
446 assert!(
447 file.to_string_lossy().starts_with(&scope),
448 "{scope} must be a prefix of the discovered {}",
449 file.display()
450 );
451 assert_eq!(
452 ClaudeCode::default().reconcile_scope(),
453 None,
454 "the stock adapter still speaks for every claude-code row"
455 );
456 }
457}