session_discovery.rs (8368B)
1 //! Discovers resumable Claude Code sessions from the filesystem. 2 //! 3 //! Claude Code stores session data in ~/.claude/projects/<project-path>/ 4 //! where <project-path> is the cwd with slashes replaced by dashes and leading slash removed. 5 6 use serde::Deserialize; 7 use std::fs::{self, File}; 8 use std::io::{BufRead, BufReader}; 9 use std::path::{Path, PathBuf}; 10 11 /// Information about a resumable Claude session 12 #[derive(Debug, Clone)] 13 pub struct ResumableSession { 14 /// The UUID session identifier used by Claude CLI 15 pub session_id: String, 16 /// Path to the session JSONL file 17 pub file_path: PathBuf, 18 /// Timestamp of the most recent message 19 pub last_timestamp: chrono::DateTime<chrono::Utc>, 20 /// Summary/title derived from first user message 21 pub summary: String, 22 /// Number of messages in the session 23 pub message_count: usize, 24 } 25 26 /// A message entry from the JSONL file 27 #[derive(Deserialize)] 28 struct SessionEntry { 29 #[serde(rename = "sessionId")] 30 session_id: Option<String>, 31 timestamp: Option<String>, 32 #[serde(rename = "type")] 33 entry_type: Option<String>, 34 message: Option<MessageContent>, 35 } 36 37 #[derive(Deserialize)] 38 struct MessageContent { 39 role: Option<String>, 40 content: Option<serde_json::Value>, 41 } 42 43 /// Converts a working directory to its Claude project path 44 /// e.g., /home/jb55/dev/notedeck-dave -> -home-jb55-dev-notedeck-dave 45 fn cwd_to_project_path(cwd: &Path) -> String { 46 let path_str = cwd.to_string_lossy(); 47 // Replace path separators with dashes, keep the leading dash 48 path_str.replace('/', "-") 49 } 50 51 /// Get the Claude projects directory 52 fn get_claude_projects_dir() -> Option<PathBuf> { 53 dirs::home_dir().map(|home| home.join(".claude").join("projects")) 54 } 55 56 /// Extract the first user message content as a summary 57 fn extract_first_user_message(content: &serde_json::Value) -> Option<String> { 58 match content { 59 serde_json::Value::String(s) => { 60 // Clean up the message - remove "Human: " prefix if present 61 let cleaned = s.trim().strip_prefix("Human:").unwrap_or(s).trim(); 62 // Take first 60 chars 63 let summary: String = cleaned.chars().take(60).collect(); 64 if cleaned.len() > 60 { 65 Some(format!("{}...", summary)) 66 } else { 67 Some(summary.to_string()) 68 } 69 } 70 serde_json::Value::Array(arr) => { 71 // Content might be an array of content blocks 72 for item in arr { 73 if let Some(text) = item.get("text").and_then(|t| t.as_str()) { 74 let summary: String = text.chars().take(60).collect(); 75 if text.len() > 60 { 76 return Some(format!("{}...", summary)); 77 } else { 78 return Some(summary.to_string()); 79 } 80 } 81 } 82 None 83 } 84 _ => None, 85 } 86 } 87 88 /// Parse a session JSONL file to extract session info 89 fn parse_session_file(path: &Path) -> Option<ResumableSession> { 90 let file = File::open(path).ok()?; 91 let reader = BufReader::new(file); 92 93 let mut session_id: Option<String> = None; 94 let mut last_timestamp: Option<chrono::DateTime<chrono::Utc>> = None; 95 let mut first_user_message: Option<String> = None; 96 let mut message_count = 0; 97 98 for line in reader.lines() { 99 let line = line.ok()?; 100 if line.trim().is_empty() { 101 continue; 102 } 103 104 if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line) { 105 // Get session ID from first entry that has it 106 if session_id.is_none() { 107 session_id = entry.session_id.clone(); 108 } 109 110 // Track timestamp 111 if let Some(ts_str) = &entry.timestamp { 112 if let Ok(ts) = ts_str.parse::<chrono::DateTime<chrono::Utc>>() { 113 if last_timestamp.is_none() || ts > last_timestamp.unwrap() { 114 last_timestamp = Some(ts); 115 } 116 } 117 } 118 119 // Count user/assistant messages 120 if matches!( 121 entry.entry_type.as_deref(), 122 Some("user") | Some("assistant") 123 ) { 124 message_count += 1; 125 126 // Get first user message for summary 127 if entry.entry_type.as_deref() == Some("user") && first_user_message.is_none() { 128 if let Some(msg) = &entry.message { 129 if msg.role.as_deref() == Some("user") { 130 if let Some(content) = &msg.content { 131 first_user_message = extract_first_user_message(content); 132 } 133 } 134 } 135 } 136 } 137 } 138 } 139 140 // Need at least a session_id and some messages 141 let session_id = session_id?; 142 if message_count == 0 { 143 return None; 144 } 145 146 Some(ResumableSession { 147 session_id, 148 file_path: path.to_path_buf(), 149 last_timestamp: last_timestamp.unwrap_or_else(chrono::Utc::now), 150 summary: first_user_message.unwrap_or_else(|| "(no summary)".to_string()), 151 message_count, 152 }) 153 } 154 155 /// Discover all resumable sessions for a given working directory 156 pub fn discover_sessions(cwd: &Path) -> Vec<ResumableSession> { 157 let projects_dir = match get_claude_projects_dir() { 158 Some(dir) => dir, 159 None => return Vec::new(), 160 }; 161 162 let project_path = cwd_to_project_path(cwd); 163 let session_dir = projects_dir.join(&project_path); 164 165 if !session_dir.exists() || !session_dir.is_dir() { 166 return Vec::new(); 167 } 168 169 let mut sessions = Vec::new(); 170 171 // Read all .jsonl files in the session directory 172 if let Ok(entries) = fs::read_dir(&session_dir) { 173 for entry in entries.flatten() { 174 let path = entry.path(); 175 if path.extension().is_some_and(|ext| ext == "jsonl") { 176 if let Some(session) = parse_session_file(&path) { 177 sessions.push(session); 178 } 179 } 180 } 181 } 182 183 // Sort by most recent first 184 sessions.sort_by(|a, b| b.last_timestamp.cmp(&a.last_timestamp)); 185 186 sessions 187 } 188 189 /// Format a timestamp for display (relative time like "2 hours ago") 190 pub fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String { 191 let now = chrono::Utc::now(); 192 let duration = now.signed_duration_since(*timestamp); 193 194 if duration.num_seconds() < 60 { 195 "just now".to_string() 196 } else if duration.num_minutes() < 60 { 197 let mins = duration.num_minutes(); 198 format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" }) 199 } else if duration.num_hours() < 24 { 200 let hours = duration.num_hours(); 201 format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" }) 202 } else if duration.num_days() < 7 { 203 let days = duration.num_days(); 204 format!("{} day{} ago", days, if days == 1 { "" } else { "s" }) 205 } else { 206 timestamp.format("%Y-%m-%d").to_string() 207 } 208 } 209 210 #[cfg(test)] 211 mod tests { 212 use super::*; 213 214 #[test] 215 fn test_cwd_to_project_path() { 216 assert_eq!( 217 cwd_to_project_path(Path::new("/home/jb55/dev/notedeck-dave")), 218 "-home-jb55-dev-notedeck-dave" 219 ); 220 assert_eq!(cwd_to_project_path(Path::new("/tmp/test")), "-tmp-test"); 221 } 222 223 #[test] 224 fn test_extract_first_user_message_string() { 225 let content = serde_json::json!("Human: Hello, world!\n\n"); 226 let result = extract_first_user_message(&content); 227 assert_eq!(result, Some("Hello, world!".to_string())); 228 } 229 230 #[test] 231 fn test_extract_first_user_message_array() { 232 let content = serde_json::json!([{"type": "text", "text": "Test message"}]); 233 let result = extract_first_user_message(&content); 234 assert_eq!(result, Some("Test message".to_string())); 235 } 236 237 #[test] 238 fn test_extract_first_user_message_truncation() { 239 let long_content = serde_json::json!("Human: This is a very long message that should be truncated because it exceeds sixty characters in length"); 240 let result = extract_first_user_message(&long_content); 241 assert!(result.unwrap().ends_with("...")); 242 } 243 }