1use std::collections::{HashMap, HashSet};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::UNIX_EPOCH;
14
15use serde_json::Value;
16
17use crate::types::{SDKSessionInfo, SessionMessage};
18
19const MAX_SANITIZED_LENGTH: usize = 200;
20
21#[derive(Debug, Clone)]
22struct TranscriptEntry {
23 entry_type: String,
24 uuid: String,
25 parent_uuid: Option<String>,
26 session_id: Option<String>,
27 message: Option<Value>,
28 is_sidechain: bool,
29 is_meta: bool,
30 team_name: Option<String>,
31}
32
33fn validate_uuid(maybe_uuid: &str) -> bool {
34 if maybe_uuid.len() != 36 {
35 return false;
36 }
37 for (i, ch) in maybe_uuid.chars().enumerate() {
38 let is_dash = matches!(i, 8 | 13 | 18 | 23);
39 if is_dash {
40 if ch != '-' {
41 return false;
42 }
43 } else if !ch.is_ascii_hexdigit() {
44 return false;
45 }
46 }
47 true
48}
49
50fn simple_hash(input: &str) -> String {
51 let mut hash: i32 = 0;
52 for ch in input.chars() {
53 hash = hash
54 .wrapping_shl(5)
55 .wrapping_sub(hash)
56 .wrapping_add(ch as i32);
57 }
58 let mut value = hash.unsigned_abs() as u64;
59 if value == 0 {
60 return "0".to_string();
61 }
62 let digits = b"0123456789abcdefghijklmnopqrstuvwxyz";
63 let mut out = Vec::new();
64 while value > 0 {
65 out.push(digits[(value % 36) as usize] as char);
66 value /= 36;
67 }
68 out.iter().rev().collect()
69}
70
71fn sanitize_path(name: &str) -> String {
72 let mut out = String::with_capacity(name.len());
73 for ch in name.chars() {
74 if ch.is_ascii_alphanumeric() {
75 out.push(ch);
76 } else {
77 out.push('-');
78 }
79 }
80 if out.len() <= MAX_SANITIZED_LENGTH {
81 return out;
82 }
83 format!("{}-{}", &out[..MAX_SANITIZED_LENGTH], simple_hash(name))
84}
85
86fn claude_config_home_dir() -> PathBuf {
87 if let Ok(path) = std::env::var("CLAUDE_CONFIG_DIR") {
88 return PathBuf::from(path);
89 }
90 if let Ok(home) = std::env::var("HOME") {
91 return PathBuf::from(home).join(".claude");
92 }
93 if let Ok(home) = std::env::var("USERPROFILE") {
94 return PathBuf::from(home).join(".claude");
95 }
96 PathBuf::from(".claude")
97}
98
99fn projects_dir() -> PathBuf {
100 claude_config_home_dir().join("projects")
101}
102
103fn canonicalize_dir(directory: &str) -> String {
104 fs::canonicalize(directory)
105 .unwrap_or_else(|_| PathBuf::from(directory))
106 .to_string_lossy()
107 .to_string()
108}
109
110fn find_project_dir(project_path: &str) -> Option<PathBuf> {
111 let sanitized = sanitize_path(project_path);
112 let exact = projects_dir().join(&sanitized);
113 if exact.is_dir() {
114 return Some(exact);
115 }
116
117 if sanitized.len() <= MAX_SANITIZED_LENGTH {
118 return None;
119 }
120
121 let prefix = &sanitized[..MAX_SANITIZED_LENGTH];
122 let entries = fs::read_dir(projects_dir()).ok()?;
123 for entry in entries.flatten() {
124 if !entry.path().is_dir() {
125 continue;
126 }
127 if let Some(name) = entry.file_name().to_str()
128 && name.starts_with(&(prefix.to_string() + "-"))
129 {
130 return Some(entry.path());
131 }
132 }
133 None
134}
135
136fn parse_jsonl(content: &str) -> Vec<Value> {
137 content
138 .lines()
139 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
140 .collect()
141}
142
143fn extract_command_name(text: &str) -> Option<String> {
144 let start_tag = "<command-name>";
145 let end_tag = "</command-name>";
146 let start = text.find(start_tag)?;
147 let after_start = start + start_tag.len();
148 let end = text[after_start..].find(end_tag)?;
149 Some(text[after_start..after_start + end].trim().to_string())
150}
151
152fn is_skipped_first_prompt(text: &str) -> bool {
153 let trimmed = text.trim();
154 trimmed.is_empty()
155 || trimmed.starts_with("<local-command-stdout>")
156 || trimmed.starts_with("<session-start-hook>")
157 || trimmed.starts_with("<tick>")
158 || trimmed.starts_with("<goal>")
159 || trimmed.starts_with("[Request interrupted by user")
160 || (trimmed.starts_with("<ide_opened_file>") && trimmed.ends_with("</ide_opened_file>"))
161 || (trimmed.starts_with("<ide_selection>") && trimmed.ends_with("</ide_selection>"))
162}
163
164fn extract_first_prompt(entries: &[Value]) -> Option<String> {
165 let mut command_fallback: Option<String> = None;
166
167 for entry in entries {
168 let Some(obj) = entry.as_object() else {
169 continue;
170 };
171 if obj.get("type").and_then(Value::as_str) != Some("user") {
172 continue;
173 }
174 if obj.get("isMeta").and_then(Value::as_bool).unwrap_or(false) {
175 continue;
176 }
177 if obj
178 .get("isCompactSummary")
179 .and_then(Value::as_bool)
180 .unwrap_or(false)
181 {
182 continue;
183 }
184
185 let Some(message) = obj.get("message").and_then(Value::as_object) else {
186 continue;
187 };
188 let Some(content) = message.get("content") else {
189 continue;
190 };
191
192 let texts: Vec<String> = if let Some(text) = content.as_str() {
193 vec![text.to_string()]
194 } else if let Some(blocks) = content.as_array() {
195 blocks
196 .iter()
197 .filter_map(|block| {
198 let block_obj = block.as_object()?;
199 if block_obj.get("type").and_then(Value::as_str) == Some("text") {
200 block_obj
201 .get("text")
202 .and_then(Value::as_str)
203 .map(ToString::to_string)
204 } else {
205 None
206 }
207 })
208 .collect()
209 } else {
210 Vec::new()
211 };
212
213 for raw in texts {
214 let candidate = raw.replace('\n', " ").trim().to_string();
215 if candidate.is_empty() {
216 continue;
217 }
218
219 if let Some(name) = extract_command_name(&candidate) {
220 if command_fallback.is_none() && !name.is_empty() {
221 command_fallback = Some(name);
222 }
223 continue;
224 }
225
226 if is_skipped_first_prompt(&candidate) {
227 continue;
228 }
229
230 if candidate.chars().count() > 200 {
231 let truncated: String = candidate.chars().take(200).collect();
232 return Some(format!("{}...", truncated.trim_end()));
233 }
234 return Some(candidate);
235 }
236 }
237
238 command_fallback
239}
240
241fn extract_last_string_field(entries: &[Value], key: &str) -> Option<String> {
242 entries.iter().rev().find_map(|entry| {
243 entry
244 .as_object()?
245 .get(key)?
246 .as_str()
247 .map(ToString::to_string)
248 })
249}
250
251fn extract_first_string_field(entries: &[Value], key: &str) -> Option<String> {
252 entries.iter().find_map(|entry| {
253 entry
254 .as_object()?
255 .get(key)?
256 .as_str()
257 .map(ToString::to_string)
258 })
259}
260
261fn millis_since_epoch(modified: std::time::SystemTime) -> i64 {
262 modified
263 .duration_since(UNIX_EPOCH)
264 .map(|d| d.as_millis() as i64)
265 .unwrap_or(0)
266}
267
268fn read_sessions_from_dir(project_dir: &Path, project_path: Option<&str>) -> Vec<SDKSessionInfo> {
269 let mut results = Vec::new();
270 let entries = match fs::read_dir(project_dir) {
271 Ok(entries) => entries,
272 Err(_) => return results,
273 };
274
275 for entry in entries.flatten() {
276 let path = entry.path();
277 if !path.is_file() {
278 continue;
279 }
280 if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
281 continue;
282 }
283
284 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
285 continue;
286 };
287 if !validate_uuid(stem) {
288 continue;
289 }
290
291 let metadata = match fs::metadata(&path) {
292 Ok(metadata) => metadata,
293 Err(_) => continue,
294 };
295 let content = match fs::read_to_string(&path) {
296 Ok(content) => content,
297 Err(_) => continue,
298 };
299 if content.trim().is_empty() {
300 continue;
301 }
302
303 let first_line = content.lines().next().unwrap_or_default();
304 if first_line.contains("\"isSidechain\":true")
305 || first_line.contains("\"isSidechain\": true")
306 {
307 continue;
308 }
309
310 let entries = parse_jsonl(&content);
311 let custom_title = extract_last_string_field(&entries, "customTitle");
312 let first_prompt = extract_first_prompt(&entries);
313 let summary = custom_title
314 .clone()
315 .or_else(|| extract_last_string_field(&entries, "summary"))
316 .or_else(|| first_prompt.clone());
317 let Some(summary) = summary else {
318 continue;
319 };
320
321 let git_branch = extract_last_string_field(&entries, "gitBranch")
322 .or_else(|| extract_first_string_field(&entries, "gitBranch"));
323 let cwd = extract_first_string_field(&entries, "cwd")
324 .or_else(|| project_path.map(ToString::to_string));
325
326 results.push(SDKSessionInfo {
327 session_id: stem.to_string(),
328 summary,
329 last_modified: metadata
330 .modified()
331 .map(millis_since_epoch)
332 .unwrap_or_default(),
333 file_size: metadata.len(),
334 custom_title,
335 first_prompt,
336 git_branch,
337 cwd,
338 });
339 }
340
341 results
342}
343
344fn get_worktree_paths(cwd: &str) -> Vec<String> {
345 let output = match Command::new("git")
346 .args(["worktree", "list", "--porcelain"])
347 .current_dir(cwd)
348 .output()
349 {
350 Ok(output) => output,
351 Err(_) => return Vec::new(),
352 };
353
354 if !output.status.success() {
355 return Vec::new();
356 }
357
358 String::from_utf8_lossy(&output.stdout)
359 .lines()
360 .filter_map(|line| line.strip_prefix("worktree ").map(ToString::to_string))
361 .collect()
362}
363
364fn deduplicate_and_sort(
365 mut sessions: Vec<SDKSessionInfo>,
366 limit: Option<usize>,
367) -> Vec<SDKSessionInfo> {
368 let mut by_id: HashMap<String, SDKSessionInfo> = HashMap::new();
369 for session in sessions.drain(..) {
370 let replace = by_id
371 .get(&session.session_id)
372 .map(|existing| session.last_modified > existing.last_modified)
373 .unwrap_or(true);
374 if replace {
375 by_id.insert(session.session_id.clone(), session);
376 }
377 }
378
379 let mut values: Vec<SDKSessionInfo> = by_id.into_values().collect();
380 values.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
381 if let Some(limit) = limit
382 && limit > 0
383 {
384 values.truncate(limit);
385 }
386 values
387}
388
389fn list_sessions_for_project(
390 directory: &str,
391 limit: Option<usize>,
392 include_worktrees: bool,
393) -> Vec<SDKSessionInfo> {
394 let canonical = canonicalize_dir(directory);
395 let mut candidates = vec![canonical.clone()];
396 if include_worktrees {
397 for path in get_worktree_paths(&canonical) {
398 if !candidates.iter().any(|candidate| candidate == &path) {
399 candidates.push(path);
400 }
401 }
402 }
403
404 let mut all = Vec::new();
405 let mut seen = HashSet::new();
406 for candidate in candidates {
407 let Some(project_dir) = find_project_dir(&candidate) else {
408 continue;
409 };
410 let key = project_dir.to_string_lossy().to_string();
411 if seen.contains(&key) {
412 continue;
413 }
414 seen.insert(key);
415 all.extend(read_sessions_from_dir(&project_dir, Some(&candidate)));
416 }
417 deduplicate_and_sort(all, limit)
418}
419
420pub fn list_sessions(
422 directory: Option<&str>,
423 limit: Option<usize>,
424 include_worktrees: bool,
425) -> Vec<SDKSessionInfo> {
426 if let Some(directory) = directory {
427 return list_sessions_for_project(directory, limit, include_worktrees);
428 }
429
430 let mut all = Vec::new();
431 let entries = match fs::read_dir(projects_dir()) {
432 Ok(entries) => entries,
433 Err(_) => return all,
434 };
435 for entry in entries.flatten() {
436 let path = entry.path();
437 if path.is_dir() {
438 all.extend(read_sessions_from_dir(&path, None));
439 }
440 }
441
442 deduplicate_and_sort(all, limit)
443}
444
445fn parse_transcript_entries(content: &str) -> Vec<TranscriptEntry> {
446 content
447 .lines()
448 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
449 .filter_map(|entry| {
450 let obj = entry.as_object()?;
451 let entry_type = obj.get("type")?.as_str()?.to_string();
452 if !matches!(
453 entry_type.as_str(),
454 "user" | "assistant" | "progress" | "system" | "attachment"
455 ) {
456 return None;
457 }
458 let uuid = obj.get("uuid")?.as_str()?.to_string();
459 Some(TranscriptEntry {
460 entry_type,
461 uuid,
462 parent_uuid: obj
463 .get("parentUuid")
464 .and_then(Value::as_str)
465 .map(ToString::to_string),
466 session_id: obj
467 .get("sessionId")
468 .and_then(Value::as_str)
469 .map(ToString::to_string),
470 message: obj.get("message").cloned(),
471 is_sidechain: obj
472 .get("isSidechain")
473 .and_then(Value::as_bool)
474 .unwrap_or(false),
475 is_meta: obj.get("isMeta").and_then(Value::as_bool).unwrap_or(false),
476 team_name: obj
477 .get("teamName")
478 .and_then(Value::as_str)
479 .map(ToString::to_string),
480 })
481 })
482 .collect()
483}
484
485fn build_conversation_chain(entries: &[TranscriptEntry]) -> Vec<TranscriptEntry> {
486 if entries.is_empty() {
487 return Vec::new();
488 }
489
490 let mut by_uuid = HashMap::new();
491 let mut entry_index = HashMap::new();
492 for (index, entry) in entries.iter().enumerate() {
493 by_uuid.insert(entry.uuid.clone(), entry.clone());
494 entry_index.insert(entry.uuid.clone(), index);
495 }
496
497 let parent_uuids: HashSet<String> = entries
498 .iter()
499 .filter_map(|entry| entry.parent_uuid.clone())
500 .collect();
501 let terminals: Vec<TranscriptEntry> = entries
502 .iter()
503 .filter(|entry| !parent_uuids.contains(&entry.uuid))
504 .cloned()
505 .collect();
506
507 let mut leaves = Vec::new();
508 for terminal in terminals {
509 let mut current = Some(terminal);
510 let mut seen = HashSet::new();
511 while let Some(entry) = current {
512 if !seen.insert(entry.uuid.clone()) {
513 break;
514 }
515 if matches!(entry.entry_type.as_str(), "user" | "assistant") {
516 leaves.push(entry);
517 break;
518 }
519 current = entry
520 .parent_uuid
521 .as_ref()
522 .and_then(|uuid| by_uuid.get(uuid))
523 .cloned();
524 }
525 }
526
527 if leaves.is_empty() {
528 return Vec::new();
529 }
530
531 let main_leaves: Vec<TranscriptEntry> = leaves
532 .iter()
533 .filter(|leaf| !leaf.is_sidechain && !leaf.is_meta && leaf.team_name.is_none())
534 .cloned()
535 .collect();
536
537 let source = if main_leaves.is_empty() {
538 &leaves
539 } else {
540 &main_leaves
541 };
542 let leaf = source
543 .iter()
544 .max_by_key(|entry| entry_index.get(&entry.uuid).copied().unwrap_or(0))
545 .cloned();
546
547 let Some(mut current) = leaf else {
548 return Vec::new();
549 };
550
551 let mut chain = Vec::new();
552 let mut seen = HashSet::new();
553 loop {
554 if !seen.insert(current.uuid.clone()) {
555 break;
556 }
557 chain.push(current.clone());
558 let Some(parent_uuid) = current.parent_uuid.clone() else {
559 break;
560 };
561 let Some(parent) = by_uuid.get(&parent_uuid).cloned() else {
562 break;
563 };
564 current = parent;
565 }
566
567 chain.reverse();
568 chain
569}
570
571fn is_visible_message(entry: &TranscriptEntry) -> bool {
572 matches!(entry.entry_type.as_str(), "user" | "assistant")
573 && !entry.is_meta
574 && !entry.is_sidechain
575 && entry.team_name.is_none()
576}
577
578fn read_session_file(session_id: &str, directory: Option<&str>) -> Option<String> {
579 let file_name = format!("{session_id}.jsonl");
580
581 if let Some(directory) = directory {
582 let canonical = canonicalize_dir(directory);
583 let mut candidates = vec![canonical.clone()];
584 for path in get_worktree_paths(&canonical) {
585 if !candidates.iter().any(|candidate| candidate == &path) {
586 candidates.push(path);
587 }
588 }
589
590 for candidate in candidates {
591 let Some(project_dir) = find_project_dir(&candidate) else {
592 continue;
593 };
594 let path = project_dir.join(&file_name);
595 if let Ok(content) = fs::read_to_string(path) {
596 return Some(content);
597 }
598 }
599 return None;
600 }
601
602 let projects = fs::read_dir(projects_dir()).ok()?;
603 for project in projects.flatten() {
604 let path = project.path().join(&file_name);
605 if let Ok(content) = fs::read_to_string(path) {
606 return Some(content);
607 }
608 }
609 None
610}
611
612pub fn get_session_messages(
614 session_id: &str,
615 directory: Option<&str>,
616 limit: Option<usize>,
617 offset: usize,
618) -> Vec<SessionMessage> {
619 if !validate_uuid(session_id) {
620 return Vec::new();
621 }
622
623 let Some(content) = read_session_file(session_id, directory) else {
624 return Vec::new();
625 };
626 let entries = parse_transcript_entries(&content);
627 let chain = build_conversation_chain(&entries);
628 let mut messages: Vec<SessionMessage> = chain
629 .into_iter()
630 .filter(is_visible_message)
631 .map(|entry| SessionMessage {
632 type_: entry.entry_type,
633 uuid: entry.uuid,
634 session_id: entry.session_id.unwrap_or_default(),
635 message: entry.message.unwrap_or(Value::Null),
636 parent_tool_use_id: None,
637 })
638 .collect();
639
640 if offset > 0 {
641 if offset >= messages.len() {
642 return Vec::new();
643 }
644 messages = messages.split_off(offset);
645 }
646
647 if let Some(limit) = limit
648 && limit > 0
649 && messages.len() > limit
650 {
651 messages.truncate(limit);
652 }
653
654 messages
655}