1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use tokio::fs;
4use tokio::io::AsyncWriteExt;
5use tracing::warn;
6use uuid::Uuid;
7
8use crate::error::{AgentError, Result};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SessionInfo {
13 pub session_id: String,
15 pub summary: String,
17 pub last_modified: u64,
19 pub file_size: u64,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub custom_title: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub first_prompt: Option<String>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub git_branch: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub cwd: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SessionMessage {
38 #[serde(rename = "type")]
39 pub message_type: String,
40 pub uuid: String,
41 pub session_id: String,
42 pub message: serde_json::Value,
43 pub parent_tool_use_id: Option<String>,
44}
45
46#[derive(Debug)]
48pub struct Session {
49 pub id: String,
50 pub cwd: String,
51 pub messages: Vec<serde_json::Value>,
52 home_override: Option<PathBuf>,
54}
55
56impl Session {
57 pub fn new(cwd: impl Into<String>) -> Self {
59 Self {
60 id: Uuid::new_v4().to_string(),
61 cwd: cwd.into(),
62 messages: Vec::new(),
63 home_override: None,
64 }
65 }
66
67 pub fn with_id(id: impl Into<String>, cwd: impl Into<String>) -> Self {
69 Self {
70 id: id.into(),
71 cwd: cwd.into(),
72 messages: Vec::new(),
73 home_override: None,
74 }
75 }
76
77 pub fn with_home(mut self, home: impl Into<PathBuf>) -> Self {
79 self.home_override = Some(home.into());
80 self
81 }
82
83 pub fn sessions_dir(cwd: &str) -> PathBuf {
85 Self::sessions_dir_with_home(cwd, home_dir_or_tmp())
86 }
87
88 pub fn sessions_dir_with_home(cwd: &str, home: PathBuf) -> PathBuf {
90 let encoded_cwd = encode_path(cwd);
91 home.join(".claude").join("projects").join(encoded_cwd)
92 }
93
94 pub fn transcript_path(&self) -> PathBuf {
96 let home = self.home_override.clone().unwrap_or_else(home_dir_or_tmp);
97 Self::sessions_dir_with_home(&self.cwd, home).join(format!("{}.jsonl", self.id))
98 }
99
100 pub async fn append_message(&self, message: &serde_json::Value) -> Result<()> {
105 let path = self.transcript_path();
106
107 if let Some(parent) = path.parent() {
109 fs::create_dir_all(parent).await?;
110 }
111
112 let mut line = serde_json::to_string(message)?;
113 line.push('\n');
114
115 let mut file = fs::OpenOptions::new()
116 .create(true)
117 .append(true)
118 .open(&path)
119 .await?;
120
121 #[cfg(unix)]
123 {
124 use std::os::unix::fs::PermissionsExt;
125 let perms = std::fs::Permissions::from_mode(0o600);
126 fs::set_permissions(&path, perms).await?;
127 }
128
129 file.write_all(line.as_bytes()).await?;
130 file.flush().await?;
131
132 Ok(())
133 }
134
135 pub async fn load_messages(&self) -> Result<Vec<serde_json::Value>> {
140 let path = self.transcript_path();
141
142 if !path.exists() {
143 return Ok(Vec::new());
144 }
145
146 let contents = fs::read_to_string(&path).await?;
147 let mut messages = Vec::new();
148
149 for (i, line) in contents.lines().enumerate() {
150 let trimmed = line.trim();
151 if trimmed.is_empty() {
152 continue;
153 }
154 match serde_json::from_str::<serde_json::Value>(trimmed) {
155 Ok(value) => messages.push(value),
156 Err(e) => {
157 warn!(
158 "Skipping malformed JSON on line {} of {}: {}",
159 i + 1,
160 path.display(),
161 e
162 );
163 }
164 }
165 }
166
167 Ok(messages)
168 }
169}
170
171pub async fn list_sessions(dir: Option<&str>, limit: Option<usize>) -> Result<Vec<SessionInfo>> {
180 list_sessions_with_home(dir, limit, home_dir_or_tmp()).await
181}
182
183pub async fn list_sessions_with_home(
185 dir: Option<&str>,
186 limit: Option<usize>,
187 home: PathBuf,
188) -> Result<Vec<SessionInfo>> {
189 let cwd = resolve_cwd(dir)?;
190 let sessions_dir = Session::sessions_dir_with_home(&cwd, home);
191
192 if !sessions_dir.exists() {
193 return Ok(Vec::new());
194 }
195
196 let mut entries = fs::read_dir(&sessions_dir).await?;
197 let mut infos: Vec<SessionInfo> = Vec::new();
198
199 while let Some(entry) = entries.next_entry().await? {
200 let path = entry.path();
201
202 let ext = path.extension().and_then(|e| e.to_str());
204 if ext != Some("jsonl") {
205 continue;
206 }
207
208 let session_id = match path.file_stem().and_then(|s| s.to_str()) {
209 Some(stem) => stem.to_string(),
210 None => continue,
211 };
212
213 let metadata = match fs::metadata(&path).await {
214 Ok(m) => m,
215 Err(_) => continue,
216 };
217
218 let last_modified = metadata
219 .modified()
220 .ok()
221 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
222 .map(|d| d.as_millis() as u64)
223 .unwrap_or(0);
224
225 let file_size = metadata.len();
226
227 let (first_prompt, custom_title) = extract_session_metadata(&path).await;
229
230 let summary = custom_title
231 .clone()
232 .or_else(|| first_prompt.clone())
233 .unwrap_or_else(|| "(empty session)".to_string());
234
235 infos.push(SessionInfo {
236 session_id,
237 summary,
238 last_modified,
239 file_size,
240 custom_title,
241 first_prompt,
242 git_branch: None,
243 cwd: Some(cwd.clone()),
244 });
245 }
246
247 infos.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
249
250 if let Some(limit) = limit {
251 infos.truncate(limit);
252 }
253
254 Ok(infos)
255}
256
257pub async fn get_session_messages(
264 session_id: &str,
265 dir: Option<&str>,
266 limit: Option<usize>,
267 offset: Option<usize>,
268) -> Result<Vec<SessionMessage>> {
269 get_session_messages_with_home(session_id, dir, limit, offset, home_dir_or_tmp()).await
270}
271
272pub async fn get_session_messages_with_home(
274 session_id: &str,
275 dir: Option<&str>,
276 limit: Option<usize>,
277 offset: Option<usize>,
278 home: PathBuf,
279) -> Result<Vec<SessionMessage>> {
280 let cwd = resolve_cwd(dir)?;
281 let session = Session::with_id(session_id, &cwd).with_home(&home);
282 let path = session.transcript_path();
283
284 if !path.exists() {
285 return Err(AgentError::SessionNotFound(session_id.to_string()));
286 }
287
288 let contents = fs::read_to_string(&path).await?;
289 let offset = offset.unwrap_or(0);
290
291 let mut messages: Vec<SessionMessage> = Vec::new();
292
293 for (i, line) in contents.lines().enumerate() {
294 let trimmed = line.trim();
295 if trimmed.is_empty() {
296 continue;
297 }
298
299 if i < offset {
301 continue;
302 }
303
304 if let Some(limit) = limit {
306 if messages.len() >= limit {
307 break;
308 }
309 }
310
311 match serde_json::from_str::<serde_json::Value>(trimmed) {
312 Ok(value) => {
313 let msg = SessionMessage {
314 message_type: value
315 .get("type")
316 .and_then(|v| v.as_str())
317 .unwrap_or("unknown")
318 .to_string(),
319 uuid: value
320 .get("uuid")
321 .and_then(|v| v.as_str())
322 .unwrap_or("")
323 .to_string(),
324 session_id: value
325 .get("session_id")
326 .and_then(|v| v.as_str())
327 .unwrap_or(session_id)
328 .to_string(),
329 message: value,
330 parent_tool_use_id: None,
331 };
332 messages.push(msg);
333 }
334 Err(e) => {
335 warn!(
336 "Skipping malformed JSON on line {} of {}: {}",
337 i + 1,
338 path.display(),
339 e
340 );
341 }
342 }
343 }
344
345 Ok(messages)
346}
347
348pub async fn find_most_recent_session(dir: Option<&str>) -> Result<Option<SessionInfo>> {
353 let sessions = list_sessions(dir, Some(1)).await?;
354 Ok(sessions.into_iter().next())
355}
356
357pub async fn find_most_recent_session_with_home(
359 dir: Option<&str>,
360 home: PathBuf,
361) -> Result<Option<SessionInfo>> {
362 let sessions = list_sessions_with_home(dir, Some(1), home).await?;
363 Ok(sessions.into_iter().next())
364}
365
366fn encode_path(path: &str) -> String {
373 path.chars()
374 .map(|c| if c.is_alphanumeric() { c } else { '-' })
375 .collect()
376}
377
378fn home_dir_or_tmp() -> PathBuf {
380 std::env::var("HOME")
381 .ok()
382 .map(PathBuf::from)
383 .unwrap_or_else(|| PathBuf::from("/tmp"))
384}
385
386fn resolve_cwd(dir: Option<&str>) -> Result<String> {
389 match dir {
390 Some(d) => Ok(d.to_string()),
391 None => std::env::current_dir()
392 .map(|p| p.to_string_lossy().into_owned())
393 .map_err(AgentError::Io),
394 }
395}
396
397async fn extract_session_metadata(path: &PathBuf) -> (Option<String>, Option<String>) {
402 let contents = match fs::read_to_string(path).await {
403 Ok(c) => c,
404 Err(_) => return (None, None),
405 };
406
407 let mut first_prompt: Option<String> = None;
408 let mut custom_title: Option<String> = None;
409
410 for line in contents.lines().take(50) {
411 let trimmed = line.trim();
412 if trimmed.is_empty() {
413 continue;
414 }
415
416 let value: serde_json::Value = match serde_json::from_str(trimmed) {
417 Ok(v) => v,
418 Err(_) => continue,
419 };
420
421 if let Some(title) = value.get("customTitle").and_then(|v| v.as_str()) {
423 if !title.is_empty() {
424 custom_title = Some(title.to_string());
425 }
426 }
427 if let Some(title) = value.get("custom_title").and_then(|v| v.as_str()) {
428 if !title.is_empty() {
429 custom_title = Some(title.to_string());
430 }
431 }
432
433 if first_prompt.is_none() {
435 if let Some("user") = value.get("type").and_then(|v| v.as_str()) {
436 if let Some(content) = value.get("content") {
437 let text = extract_text_from_content(content);
438 if !text.is_empty() {
439 let truncated = if text.len() > 200 {
441 format!("{}...", &text[..200])
442 } else {
443 text
444 };
445 first_prompt = Some(truncated);
446 }
447 }
448 }
449 }
450
451 if first_prompt.is_some() && custom_title.is_some() {
453 break;
454 }
455 }
456
457 (first_prompt, custom_title)
458}
459
460fn extract_text_from_content(content: &serde_json::Value) -> String {
465 if let Some(s) = content.as_str() {
466 return s.to_string();
467 }
468
469 if let Some(blocks) = content.as_array() {
470 let texts: Vec<&str> = blocks
471 .iter()
472 .filter_map(|block| {
473 if block.get("type").and_then(|t| t.as_str()) == Some("text") {
474 block.get("text").and_then(|t| t.as_str())
475 } else {
476 None
477 }
478 })
479 .collect();
480 return texts.join(" ");
481 }
482
483 String::new()
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use serde_json::json;
490 use tempfile::TempDir;
491
492 fn session_in_tmp(tmp: &TempDir) -> Session {
495 Session::new("/test/project").with_home(tmp.path())
496 }
497
498 #[tokio::test]
499 async fn test_append_and_load_roundtrip() {
500 let tmp = TempDir::new().unwrap();
501 let session = session_in_tmp(&tmp);
502
503 let msg1 = json!({"type": "user", "content": "hello"});
504 let msg2 = json!({"type": "assistant", "content": "world"});
505
506 session.append_message(&msg1).await.unwrap();
507 session.append_message(&msg2).await.unwrap();
508
509 let loaded = session.load_messages().await.unwrap();
510 assert_eq!(loaded.len(), 2);
511 assert_eq!(loaded[0]["content"], "hello");
512 assert_eq!(loaded[1]["content"], "world");
513 }
514
515 #[tokio::test]
516 async fn test_load_messages_empty_file() {
517 let tmp = TempDir::new().unwrap();
518 let session = session_in_tmp(&tmp);
519
520 let loaded = session.load_messages().await.unwrap();
522 assert!(loaded.is_empty());
523 }
524
525 #[tokio::test]
526 async fn test_transcript_path_encoding() {
527 let session = Session::with_id("abc-123", "/home/user/my project");
528 let path = session.transcript_path();
529 let path_str = path.to_string_lossy();
530
531 assert!(path_str.contains("-home-user-my-project"));
533 assert!(path_str.ends_with("abc-123.jsonl"));
534 }
535
536 #[tokio::test]
537 async fn test_list_sessions_and_find_most_recent() {
538 let tmp = TempDir::new().unwrap();
539 let home = tmp.path().to_path_buf();
540
541 let cwd = "/test/project";
542
543 let s1 = Session::with_id("session-1", cwd).with_home(&home);
545 let s2 = Session::with_id("session-2", cwd).with_home(&home);
546
547 s1.append_message(
548 &json!({"type": "user", "content": [{"type": "text", "text": "first prompt"}]}),
549 )
550 .await
551 .unwrap();
552
553 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
555
556 s2.append_message(&json!({"type": "user", "content": "second session prompt"}))
557 .await
558 .unwrap();
559
560 let sessions = list_sessions_with_home(Some(cwd), None, home.clone())
561 .await
562 .unwrap();
563 assert_eq!(sessions.len(), 2);
564
565 assert_eq!(sessions[0].session_id, "session-2");
567 assert_eq!(sessions[1].session_id, "session-1");
568
569 let sessions = list_sessions_with_home(Some(cwd), Some(1), home.clone())
571 .await
572 .unwrap();
573 assert_eq!(sessions.len(), 1);
574 assert_eq!(sessions[0].session_id, "session-2");
575
576 let recent = find_most_recent_session_with_home(Some(cwd), home.clone())
578 .await
579 .unwrap();
580 assert!(recent.is_some());
581 assert_eq!(recent.unwrap().session_id, "session-2");
582 }
583
584 #[tokio::test]
585 async fn test_get_session_messages_pagination() {
586 let tmp = TempDir::new().unwrap();
587 let home = tmp.path().to_path_buf();
588
589 let cwd = "/test/project";
590 let session = Session::with_id("paginated", cwd).with_home(&home);
591
592 for i in 0..10 {
593 session
594 .append_message(&json!({"type": "user", "content": format!("msg {}", i)}))
595 .await
596 .unwrap();
597 }
598
599 let all = get_session_messages_with_home("paginated", Some(cwd), None, None, home.clone())
601 .await
602 .unwrap();
603 assert_eq!(all.len(), 10);
604
605 let page =
607 get_session_messages_with_home("paginated", Some(cwd), Some(3), Some(2), home.clone())
608 .await
609 .unwrap();
610 assert_eq!(page.len(), 3);
611 assert_eq!(page[0].message["content"], "msg 2");
612
613 let err =
615 get_session_messages_with_home("nonexistent", Some(cwd), None, None, home.clone())
616 .await;
617 assert!(err.is_err());
618 }
619}