1use std::path::PathBuf;
2
3use jiff::Timestamp;
4use rootcause::option_ext::OptionExt;
5use rootcause::prelude::ResultExt;
6use serde::Deserialize;
7use serde::de::IgnoredAny;
8
9use crate::agent::Agent;
10use crate::agent::session::SearchTextBuilder;
11use crate::agent::session::Session;
12
13pub fn parse(content: &str) -> rootcause::Result<ClaudeSession> {
18 let mut session_id = None;
19 let mut workspace_dir = None;
20 let mut created_at = None;
21 let mut updated_at = None;
22 let mut first_user_message = None;
23 let mut search_text = SearchTextBuilder::default();
24
25 for (line_idx, line) in content.lines().enumerate() {
26 let line = serde_json::from_str::<ClaudeSessionLine>(line)
27 .context("failed to parse Claude session json line".to_owned())
28 .attach(format!("line_number={}", line_idx.saturating_add(1)))
29 .attach(format!("line={line}"))?;
30
31 if let Some(timestamp) = line.timestamp() {
32 updated_at = Some(timestamp);
33 }
34
35 if let Some(meta) = line.session_meta() {
36 session_id.get_or_insert_with(|| meta.session_id.to_owned());
37 workspace_dir.get_or_insert_with(|| PathBuf::from(meta.cwd));
38 created_at.get_or_insert(meta.timestamp);
39 }
40
41 if let Some(user_message) = line.user_search_text() {
42 if first_user_message.is_none() {
43 first_user_message = Some(user_message.clone());
44 }
45 search_text.push(&user_message);
46 }
47 if let Some(assistant_message) = line.assistant_search_text() {
48 search_text.push(&assistant_message);
49 }
50 }
51
52 let session_id = session_id.context("no Claude session record found".to_owned())?;
53 let workspace_dir = workspace_dir.context("no Claude session record found".to_owned())?;
54 let created_at = created_at.context("no Claude session record found".to_owned())?;
55
56 let name = first_user_message.unwrap_or_else(|| {
57 workspace_dir
58 .file_name()
59 .and_then(|name| name.to_str())
60 .filter(|name| !name.is_empty())
61 .map_or_else(|| session_id.clone(), str::to_owned)
62 });
63 let search_text = search_text.build(&name);
64
65 Ok(ClaudeSession {
66 id: session_id,
67 name,
68 search_text,
69 workspace: workspace_dir,
70 created_at,
71 updated_at: updated_at.unwrap_or(created_at),
72 })
73}
74
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct ClaudeSession {
77 pub id: String,
78 pub name: String,
79 pub search_text: String,
80 pub workspace: PathBuf,
81 pub created_at: Timestamp,
82 pub updated_at: Timestamp,
83}
84
85impl ClaudeSession {
86 pub fn into_session(self, path: PathBuf) -> Session {
87 let mut session = Session::new(Agent::Claude, self.id, self.workspace, path, None, self.created_at);
88 session.name = self.name;
89 session.search_text = self.search_text;
90 session.updated_at = self.updated_at;
91 session
92 }
93}
94
95#[derive(Debug, Deserialize)]
96#[serde(tag = "type")]
97enum ClaudeSessionLine {
98 #[serde(rename = "user")]
99 User(ClaudeUserLine),
100 #[serde(rename = "assistant")]
101 Assistant(ClaudeAssistantLine),
102 #[serde(rename = "progress")]
103 #[serde(alias = "system")]
104 #[serde(alias = "attachment")]
105 Metadata(ClaudeMetadataLine),
106 #[serde(rename = "queue-operation")]
107 TimestampOnly(ClaudeTimestampedLine),
108 #[serde(other)]
109 Other,
110}
111
112impl ClaudeSessionLine {
113 const fn timestamp(&self) -> Option<Timestamp> {
114 match self {
115 Self::User(line) => Some(line.timestamp),
116 Self::Assistant(line) => Some(line.timestamp),
117 Self::Metadata(line) => Some(line.timestamp),
118 Self::TimestampOnly(line) => Some(line.timestamp),
119 Self::Other => None,
120 }
121 }
122
123 fn session_meta(&self) -> Option<ClaudeSessionMeta<'_>> {
124 match self {
125 Self::User(line) => Some(ClaudeSessionMeta::from(line)),
126 Self::Assistant(line) => Some(ClaudeSessionMeta::from(line)),
127 Self::Metadata(line) => Some(ClaudeSessionMeta::from(line)),
128 Self::TimestampOnly(_) | Self::Other => None,
129 }
130 }
131
132 fn user_search_text(&self) -> Option<String> {
133 match self {
134 Self::User(line) if !line.is_meta => line.message.content.search_text(),
135 Self::User(_) | Self::Assistant(_) | Self::Metadata(_) | Self::TimestampOnly(_) | Self::Other => None,
136 }
137 }
138
139 fn assistant_search_text(&self) -> Option<String> {
140 match self {
141 Self::Assistant(line) => line.message.search_text(),
142 Self::User(_) | Self::Metadata(_) | Self::TimestampOnly(_) | Self::Other => None,
143 }
144 }
145}
146
147struct ClaudeSessionMeta<'a> {
148 session_id: &'a str,
149 cwd: &'a str,
150 timestamp: Timestamp,
151}
152
153#[derive(Debug, Deserialize)]
154struct ClaudeUserLine {
155 #[serde(rename = "sessionId")]
156 session_id: String,
157 cwd: String,
158 timestamp: Timestamp,
159 #[serde(default, rename = "isMeta")]
160 is_meta: bool,
161 message: ClaudeUserMessage,
162}
163
164#[derive(Debug, Deserialize)]
165struct ClaudeAssistantLine {
166 #[serde(rename = "sessionId")]
167 session_id: String,
168 cwd: String,
169 timestamp: Timestamp,
170 message: ClaudeAssistantMessage,
171}
172
173#[derive(Debug, Deserialize)]
174struct ClaudeMetadataLine {
175 #[serde(rename = "sessionId")]
176 session_id: String,
177 cwd: String,
178 timestamp: Timestamp,
179}
180
181impl<'a> From<&'a ClaudeUserLine> for ClaudeSessionMeta<'a> {
182 fn from(value: &'a ClaudeUserLine) -> Self {
183 Self {
184 session_id: &value.session_id,
185 cwd: &value.cwd,
186 timestamp: value.timestamp,
187 }
188 }
189}
190
191impl<'a> From<&'a ClaudeAssistantLine> for ClaudeSessionMeta<'a> {
192 fn from(value: &'a ClaudeAssistantLine) -> Self {
193 Self {
194 session_id: &value.session_id,
195 cwd: &value.cwd,
196 timestamp: value.timestamp,
197 }
198 }
199}
200
201impl<'a> From<&'a ClaudeMetadataLine> for ClaudeSessionMeta<'a> {
202 fn from(value: &'a ClaudeMetadataLine) -> Self {
203 Self {
204 session_id: &value.session_id,
205 cwd: &value.cwd,
206 timestamp: value.timestamp,
207 }
208 }
209}
210
211#[derive(Debug, Deserialize)]
212struct ClaudeTimestampedLine {
213 timestamp: Timestamp,
214}
215
216#[derive(Debug, Deserialize)]
217struct ClaudeUserMessage {
218 content: ClaudeUserContent,
219}
220
221#[derive(Debug, Deserialize)]
222struct ClaudeAssistantMessage {
223 #[serde(default)]
224 content: Vec<ClaudeAssistantContentPart>,
225}
226
227impl ClaudeAssistantMessage {
228 fn search_text(&self) -> Option<String> {
229 let mut search_text = SearchTextBuilder::default();
230 for snippet in self
231 .content
232 .iter()
233 .filter_map(ClaudeAssistantContentPart::assistant_search_text)
234 {
235 search_text.push(snippet);
236 }
237 let search_text = search_text.build("");
238 (!search_text.is_empty()).then_some(search_text)
239 }
240}
241
242#[cfg_attr(test, derive(PartialEq))]
243#[derive(Debug, Deserialize)]
244#[serde(untagged)]
245enum ClaudeUserContent {
246 Text(ClaudeUserText),
247 Parts(Vec<ClaudeUserContentPart>),
248}
249
250impl ClaudeUserContent {
251 fn search_text(&self) -> Option<String> {
252 match self {
253 Self::Text(text) => text.preview(),
254 Self::Parts(items) => {
255 let mut search_text = SearchTextBuilder::default();
256 for snippet in items.iter().filter_map(|item| match item {
257 ClaudeUserContentPart::Text { text } => text.preview(),
258 ClaudeUserContentPart::ToolResult { .. } | ClaudeUserContentPart::Other => None,
259 }) {
260 search_text.push(&snippet);
261 }
262 let search_text = search_text.build("");
263 (!search_text.is_empty()).then_some(search_text)
264 }
265 }
266 }
267}
268
269#[cfg_attr(test, derive(PartialEq))]
270#[derive(Debug, Deserialize)]
271#[serde(tag = "type", rename_all = "snake_case")]
272enum ClaudeUserContentPart {
273 Text {
274 text: ClaudeUserText,
275 },
276 ToolResult {
277 #[serde(rename = "content")]
278 _content: IgnoredAny,
279 },
280 #[serde(other)]
281 Other,
282}
283
284#[derive(Debug, Deserialize)]
285#[serde(tag = "type", rename_all = "snake_case")]
286enum ClaudeAssistantContentPart {
287 Text {
288 text: String,
289 },
290 #[serde(other)]
291 Other,
292}
293
294impl ClaudeAssistantContentPart {
295 fn assistant_search_text(&self) -> Option<&str> {
296 match self {
297 Self::Text { text } => Some(text),
298 Self::Other => None,
299 }
300 }
301}
302
303#[derive(Clone, Debug, Eq, PartialEq)]
304enum ClaudeUserText {
305 Plain(String),
306 Cmd(ClaudeCmdInvocation),
307}
308
309impl ClaudeUserText {
310 fn preview(&self) -> Option<String> {
311 match self {
312 Self::Plain(text)
313 if matches!(
314 text.trim_start(),
315 text if text.starts_with("<local-command-caveat>")
316 || text.starts_with("<local-command-stdout>")
317 ) =>
318 {
319 None
320 }
321 Self::Plain(text) => Some(text.clone()),
322 Self::Cmd(command) => Some(command.preview()),
323 }
324 }
325}
326
327impl<'de> Deserialize<'de> for ClaudeUserText {
328 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
329 where
330 D: serde::Deserializer<'de>,
331 {
332 let text = String::deserialize(deserializer)?;
333 Ok(self::ClaudeCmdInvocation::parse(&text)
334 .map(Self::Cmd)
335 .unwrap_or(Self::Plain(text)))
336 }
337}
338
339#[derive(Clone, Copy, Debug, Eq, PartialEq)]
340enum ClaudeCommandTag {
341 Name,
342 Args,
343}
344
345impl ClaudeCommandTag {
346 const fn open(self) -> &'static str {
347 match self {
348 Self::Name => "<command-name>",
349 Self::Args => "<command-args>",
350 }
351 }
352
353 const fn close(self) -> &'static str {
354 match self {
355 Self::Name => "</command-name>",
356 Self::Args => "</command-args>",
357 }
358 }
359}
360
361#[derive(Clone, Debug, Eq, PartialEq)]
362struct ClaudeCmdInvocation {
363 name: String,
364 args: Option<String>,
365}
366
367impl ClaudeCmdInvocation {
368 fn parse(text: &str) -> Option<Self> {
369 fn extract_tag(text: &str, tag: ClaudeCommandTag) -> Option<&str> {
370 let start = text.find(tag.open())?.saturating_add(tag.open().len());
371 let tail = text.get(start..)?;
372 let end = tail.find(tag.close())?.saturating_add(start);
373 text.get(start..end)
374 }
375
376 let name = extract_tag(text, ClaudeCommandTag::Name)
377 .map(str::trim)
378 .filter(|name| !name.is_empty())?
379 .to_owned();
380 let args = extract_tag(text, ClaudeCommandTag::Args)
381 .map(str::trim)
382 .filter(|args| !args.is_empty())
383 .map(str::to_owned);
384
385 Some(Self { name, args })
386 }
387
388 fn preview(&self) -> String {
389 let mut preview = self.name.clone();
390 if let Some(command_args) = self.args.as_deref().map(str::trim).filter(|args| !args.is_empty()) {
391 preview.push(' ');
392 preview.push_str(command_args);
393 }
394 preview
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use tempfile::tempdir;
401 use test_that::prelude::*;
402
403 use super::*;
404
405 #[test]
406 fn test_parse_claude_session_from_jsonl_lines_sets_workspace_and_id() {
407 let tempdir = tempdir().unwrap();
408 let workspace = tempdir.path().join("workspace");
409 std::fs::create_dir_all(&workspace).unwrap();
410
411 let content = concat!(
412 "{\"type\":\"file-history-snapshot\",\"messageId\":\"m1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n",
413 "{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"__CWD__\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
414 "{\"type\":\"last-prompt\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\",\"lastPrompt\":\"hello\"}\n"
415 )
416 .replace("__CWD__", &workspace.display().to_string());
417
418 let claude_session_result = parse(&content);
419 assert_that!(claude_session_result.as_ref().map(|_| ()), ok(eq(())));
420 let claude_session = claude_session_result.expect("Claude session should parse");
421 let session = claude_session.into_session(workspace.join("session.jsonl"));
422 assert_that!(session.agent, eq(Agent::Claude));
423 assert_that!(session.workspace, eq(workspace));
424 assert_that!(session.id, eq("8649a076-3ead-4d5a-9840-3200f0e1aae5"));
425 assert_that!(session.name, eq("workspace"));
426 assert_that!(session.search_text, eq("workspace"));
427 }
428
429 #[test]
430 fn test_parse_claude_session_with_invalid_scanned_line_returns_error() {
431 let content = "{\"type\":\"progress\",\"timestamp\":\"not-a-date\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n";
432
433 assert_that!(
434 parse(content).map(|_| ()),
435 err(displays_as(contains_substring(
436 "failed to parse Claude session json line"
437 )))
438 );
439 }
440
441 #[test]
442 fn test_parse_claude_session_without_metadata_returns_error() {
443 let content = "{\"type\":\"last-prompt\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\",\"lastPrompt\":\"hello\"}\n";
444 assert_that!(
445 (parse(content)).map(|_| ()),
446 err(displays_as(contains_substring("no Claude session record found")))
447 );
448 }
449
450 #[test]
451 fn test_parse_claude_session_indexes_user_and_assistant_text() {
452 let content = concat!(
453 "{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
454 "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"this is a very long first user message\"},\"timestamp\":\"2026-03-26T16:52:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
455 "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"hidden\"},{\"type\":\"text\",\"text\":\"assistant answer\"},{\"type\":\"tool_use\",\"name\":\"Read\"}]},\"timestamp\":\"2026-03-26T16:53:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n"
456 );
457
458 let claude_session_result = parse(content);
459 assert_that!(claude_session_result.as_ref().map(|_| ()), ok(eq(())));
460 let claude_session = claude_session_result.expect("Claude session should parse");
461 let session = claude_session.into_session(PathBuf::from("session.jsonl"));
462 assert_that!(session.name, eq("this is a very long first user message"));
463 assert_that!(
464 session.search_text,
465 eq("this is a very long first user message assistant answer")
466 );
467 assert_that!(
468 session.updated_at,
469 eq("2026-03-26T16:53:02.119Z".parse::<Timestamp>().unwrap())
470 );
471 }
472
473 #[test]
474 fn test_parse_claude_session_with_command_wrapper_sets_command_preview() {
475 let content = concat!(
476 "{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
477 "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"<command-message>privoly-admin</command-message>\\n<command-name>/privoly-admin</command-name>\\n<command-args>install</command-args>\"},\"timestamp\":\"2026-03-26T16:52:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n"
478 );
479
480 let claude_session_result = parse(content);
481 assert_that!(claude_session_result.as_ref().map(|_| ()), ok(eq(())));
482 let claude_session = claude_session_result.expect("Claude session should parse");
483 let session = claude_session.into_session(PathBuf::from("session.jsonl"));
484 assert_that!(session.name, eq("/privoly-admin install"));
485 assert_that!(session.search_text, eq("/privoly-admin install"));
486 }
487
488 #[test]
489 fn test_parse_claude_session_skips_meta_and_tool_result_only_user_rows() {
490 let content = concat!(
491 "{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
492 "{\"type\":\"user\",\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"<local-command-caveat>ignore me</local-command-caveat>\"},\"timestamp\":\"2026-03-26T16:52:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
493 "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"content\":\"ignored\"}]},\"timestamp\":\"2026-03-26T16:53:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
494 "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"real prompt\"},\"timestamp\":\"2026-03-26T16:54:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n"
495 );
496
497 let claude_session_result = parse(content);
498 assert_that!(claude_session_result.as_ref().map(|_| ()), ok(eq(())));
499 let claude_session = claude_session_result.expect("Claude session should parse");
500 let session = claude_session.into_session(PathBuf::from("session.jsonl"));
501 assert_that!(session.name, eq("real prompt"));
502 assert_that!(session.search_text, eq("real prompt"));
503 }
504
505 #[test]
506 fn test_user_content_search_text_with_tool_result_then_text_returns_text() {
507 let value = serde_json::from_str::<ClaudeUserContent>(
508 r#"[{"type":"tool_result","content":"ignored"},{"type":"text","text":"later text"}]"#,
509 )
510 .unwrap();
511
512 assert_that!(value.search_text(), eq(Some("later text".to_owned())));
513 }
514
515 #[test]
516 fn test_deserialize_claude_user_content_part_text_with_command_wrapper_models_command() {
517 let value = serde_json::from_str::<ClaudeUserContent>(
518 r#"[{"type":"text","text":"<command-message>privoly-admin</command-message>\n<command-name>/privoly-admin</command-name>\n<command-args>install</command-args>"}]"#,
519 )
520 .unwrap();
521
522 assert_that!(
523 value,
524 eq(ClaudeUserContent::Parts(vec![ClaudeUserContentPart::Text {
525 text: ClaudeUserText::Cmd(ClaudeCmdInvocation {
526 name: "/privoly-admin".to_owned(),
527 args: Some("install".to_owned()),
528 }),
529 }]))
530 );
531 }
532}