claude_code_sdk_rust/internal/
sessions_fs.rs1use crate::error::{ClaudeSDKError, Result};
4use crate::session_store::project_key_for_directory;
5use crate::sessions::{ListSessionsOptions, SessionInfo, SessionMessage};
6use chrono::{DateTime, Utc};
7use std::path::{Path, PathBuf};
8
9pub async fn list_sessions(opts: &ListSessionsOptions) -> Result<Vec<SessionInfo>> {
11 let mut sessions = Vec::new();
12 for project_dir in project_dirs(opts.directory.as_deref()) {
13 let Ok(files) = std::fs::read_dir(project_dir) else {
14 continue;
15 };
16 for path in files.flatten().map(|file| file.path()) {
17 if session_id_from_jsonl_path(&path).is_some() {
18 if let Some(info) = session_info_from_file(&path).await? {
19 sessions.push(info);
20 }
21 }
22 }
23 }
24
25 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
26 if let Some(offset) = opts.offset {
27 sessions = sessions.into_iter().skip(offset).collect();
28 }
29 if let Some(limit) = opts.limit {
30 sessions.truncate(limit);
31 }
32 Ok(sessions)
33}
34
35pub async fn get_session_info(session_id: &str, directory: Option<&str>) -> Result<SessionInfo> {
37 validate_session_id(session_id)?;
38 let path = resolve_session_file(session_id, directory)
39 .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))?;
40 session_info_from_file(&path)
41 .await?
42 .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))
43}
44
45pub async fn get_session_messages(
47 session_id: &str,
48 directory: Option<&str>,
49 limit: Option<usize>,
50 offset: usize,
51) -> Result<Vec<SessionMessage>> {
52 validate_session_id(session_id)?;
53 let Some(path) = resolve_session_file(session_id, directory) else {
54 return Ok(Vec::new());
55 };
56 let entries = read_jsonl_entries(&path).await?;
57 Ok(apply_limit_offset(
58 entries
59 .into_iter()
60 .filter_map(session_message_from_entry)
61 .collect(),
62 limit,
63 offset,
64 ))
65}
66
67pub async fn list_subagents(session_id: &str, directory: Option<&str>) -> Result<Vec<String>> {
69 validate_session_id(session_id)?;
70 let Some(path) = resolve_session_file(session_id, directory) else {
71 return Ok(Vec::new());
72 };
73 let subagents_dir = path.with_extension("").join("subagents");
74 Ok(collect_agent_files(&subagents_dir)
75 .into_iter()
76 .map(|(agent_id, _)| agent_id)
77 .collect())
78}
79
80pub async fn get_subagent_messages(
82 session_id: &str,
83 agent_id: &str,
84 directory: Option<&str>,
85 limit: Option<usize>,
86 offset: usize,
87) -> Result<Vec<SessionMessage>> {
88 validate_session_id(session_id)?;
89 if agent_id.is_empty() {
90 return Ok(Vec::new());
91 }
92 let Some(path) = resolve_session_file(session_id, directory) else {
93 return Ok(Vec::new());
94 };
95 let subagents_dir = path.with_extension("").join("subagents");
96 let Some((_, agent_file)) = collect_agent_files(&subagents_dir)
97 .into_iter()
98 .find(|(found_id, _)| found_id == agent_id)
99 else {
100 return Ok(Vec::new());
101 };
102 let entries = read_jsonl_entries(&agent_file).await?;
103 Ok(apply_limit_offset(
104 subagent_chain(entries)
105 .into_iter()
106 .filter_map(session_message_from_entry)
107 .collect(),
108 limit,
109 offset,
110 ))
111}
112
113pub async fn rename_session(session_id: &str, title: &str, directory: Option<&str>) -> Result<()> {
115 validate_session_id(session_id)?;
116 let title = title.trim();
117 if title.is_empty() {
118 return Err(ClaudeSDKError::Session(
119 "title must be non-empty".to_string(),
120 ));
121 }
122 let path = resolve_session_file(session_id, directory)
123 .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))?;
124 let entry = serde_json::json!({
125 "type": "summary",
126 "customTitle": title,
127 "timestamp": Utc::now().to_rfc3339(),
128 });
129 append_jsonl_entry(&path, &entry).await
130}
131
132pub async fn tag_session(
134 session_id: &str,
135 tag: Option<&str>,
136 directory: Option<&str>,
137) -> Result<()> {
138 validate_session_id(session_id)?;
139 let tag = tag.map(sanitize_tag).transpose()?.unwrap_or_default();
140 let path = resolve_session_file(session_id, directory)
141 .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))?;
142 let entry = serde_json::json!({
143 "type": "tag",
144 "tag": tag,
145 "sessionId": session_id,
146 });
147 append_jsonl_entry(&path, &entry).await
148}
149
150pub async fn delete_session(session_id: &str, directory: Option<&str>) -> Result<()> {
152 validate_session_id(session_id)?;
153 let Some(path) = resolve_session_file(session_id, directory) else {
154 return Ok(());
155 };
156 tokio::fs::remove_file(&path).await?;
157 let sidecar_dir = path.with_extension("");
158 if tokio::fs::metadata(&sidecar_dir)
159 .await
160 .is_ok_and(|meta| meta.is_dir())
161 {
162 tokio::fs::remove_dir_all(sidecar_dir).await?;
163 }
164 Ok(())
165}
166
167fn projects_dir() -> PathBuf {
168 std::env::var_os("CLAUDE_CONFIG_DIR")
169 .map(PathBuf::from)
170 .or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
171 .unwrap_or_else(|| PathBuf::from(".claude"))
172 .join("projects")
173}
174
175fn project_dirs(directory: Option<&str>) -> Vec<PathBuf> {
176 if let Some(directory) = directory {
177 let dir = projects_dir().join(project_key_for_directory(Some(Path::new(directory))));
178 return if dir.is_dir() { vec![dir] } else { Vec::new() };
179 }
180 let Ok(projects) = std::fs::read_dir(projects_dir()) else {
181 return Vec::new();
182 };
183 projects
184 .flatten()
185 .filter(|project| project.file_type().ok().is_some_and(|ty| ty.is_dir()))
186 .map(|project| project.path())
187 .collect()
188}
189
190fn resolve_session_file(session_id: &str, directory: Option<&str>) -> Option<PathBuf> {
191 let file_name = format!("{session_id}.jsonl");
192 for project in project_dirs(directory) {
193 let candidate = project.join(&file_name);
194 if candidate.is_file() {
195 return Some(candidate);
196 }
197 }
198 None
199}
200
201fn apply_limit_offset(
202 messages: Vec<SessionMessage>,
203 limit: Option<usize>,
204 offset: usize,
205) -> Vec<SessionMessage> {
206 let messages = if offset > 0 {
207 messages.into_iter().skip(offset).collect()
208 } else {
209 messages
210 };
211 if let Some(limit) = limit.filter(|limit| *limit > 0) {
212 messages.into_iter().take(limit).collect()
213 } else {
214 messages
215 }
216}
217
218fn collect_agent_files(base_dir: &Path) -> Vec<(String, PathBuf)> {
219 let mut output = Vec::new();
220 collect_agent_files_inner(base_dir, &mut output);
221 output
222}
223
224fn collect_agent_files_inner(base_dir: &Path, output: &mut Vec<(String, PathBuf)>) {
225 let Ok(entries) = std::fs::read_dir(base_dir) else {
226 return;
227 };
228 let mut entries = entries.flatten().collect::<Vec<_>>();
229 entries.sort_by_key(|entry| entry.file_name());
230 for entry in entries {
231 let path = entry.path();
232 if path.is_dir() {
233 collect_agent_files_inner(&path, output);
234 continue;
235 }
236 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
237 continue;
238 };
239 let Some(agent_id) = file_name
240 .strip_prefix("agent-")
241 .and_then(|name| name.strip_suffix(".jsonl"))
242 else {
243 continue;
244 };
245 output.push((agent_id.to_string(), path));
246 }
247}
248
249async fn session_info_from_file(path: &Path) -> Result<Option<SessionInfo>> {
250 let Some(session_id) = session_id_from_jsonl_path(path) else {
251 return Ok(None);
252 };
253 let entries = read_jsonl_entries(path).await?;
254 if entries.is_empty() {
255 return Ok(None);
256 }
257 let metadata = tokio::fs::metadata(path).await?;
258 let updated_at = metadata
259 .modified()
260 .ok()
261 .map(DateTime::<Utc>::from)
262 .unwrap_or_else(Utc::now)
263 .to_rfc3339();
264 let created_at = metadata
265 .created()
266 .ok()
267 .map(DateTime::<Utc>::from)
268 .unwrap_or_else(Utc::now)
269 .to_rfc3339();
270 let message_count = entries
271 .iter()
272 .filter(|entry| is_visible_message(entry))
273 .count();
274 let title = extract_title(&entries).unwrap_or_else(|| session_id.clone());
275
276 Ok(Some(SessionInfo {
277 id: session_id,
278 title,
279 created_at,
280 updated_at,
281 message_count,
282 }))
283}
284
285async fn read_jsonl_entries(
286 path: &Path,
287) -> Result<Vec<serde_json::Map<String, serde_json::Value>>> {
288 let content = tokio::fs::read_to_string(path).await?;
289 let entries = content
290 .lines()
291 .filter_map(|line| {
292 let line = line.trim();
293 if line.is_empty() {
294 return None;
295 }
296 serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(line).ok()
297 })
298 .collect();
299 Ok(entries)
300}
301
302async fn append_jsonl_entry(path: &Path, entry: &serde_json::Value) -> Result<()> {
303 use tokio::io::AsyncWriteExt;
304
305 let mut file = tokio::fs::OpenOptions::new()
306 .append(true)
307 .open(path)
308 .await?;
309 file.write_all(serde_json::to_string(entry)?.as_bytes())
310 .await?;
311 file.write_all(b"\n").await?;
312 Ok(())
313}
314
315fn session_message_from_entry(
316 entry: serde_json::Map<String, serde_json::Value>,
317) -> Option<SessionMessage> {
318 if !is_visible_message(&entry) {
319 return None;
320 }
321 let role = entry.get("type")?.as_str()?.to_string();
322 let id = entry
323 .get("uuid")
324 .and_then(|value| value.as_str())
325 .unwrap_or_default()
326 .to_string();
327 let content = entry
328 .get("message")
329 .and_then(|message| message.get("content"))
330 .map(content_to_string)
331 .unwrap_or_default();
332 let timestamp = entry
333 .get("timestamp")
334 .and_then(|value| value.as_str())
335 .unwrap_or_default()
336 .to_string();
337 Some(SessionMessage {
338 id,
339 role,
340 content,
341 timestamp,
342 })
343}
344
345fn subagent_chain(
346 entries: Vec<serde_json::Map<String, serde_json::Value>>,
347) -> Vec<serde_json::Map<String, serde_json::Value>> {
348 let Some(leaf_uuid) = entries
349 .iter()
350 .rev()
351 .find(|entry| matches!(entry_type(entry), Some("user" | "assistant")))
352 .and_then(|entry| entry.get("uuid"))
353 .and_then(|value| value.as_str())
354 .map(ToString::to_string)
355 else {
356 return Vec::new();
357 };
358 let by_uuid = entries
359 .iter()
360 .filter_map(|entry| {
361 entry
362 .get("uuid")
363 .and_then(|value| value.as_str())
364 .map(|uuid| (uuid.to_string(), entry.clone()))
365 })
366 .collect::<std::collections::HashMap<_, _>>();
367 let mut chain = Vec::new();
368 let mut seen = std::collections::HashSet::new();
369 let mut current = Some(leaf_uuid);
370 while let Some(uuid) = current {
371 if !seen.insert(uuid.clone()) {
372 break;
373 }
374 let Some(entry) = by_uuid.get(&uuid).cloned() else {
375 break;
376 };
377 current = entry
378 .get("parentUuid")
379 .and_then(|value| value.as_str())
380 .map(ToString::to_string);
381 chain.push(entry);
382 }
383 chain.reverse();
384 chain
385}
386
387fn extract_title(entries: &[serde_json::Map<String, serde_json::Value>]) -> Option<String> {
388 entries
389 .iter()
390 .rev()
391 .find_map(|entry| string_field(entry, "customTitle"))
392 .or_else(|| {
393 entries
394 .iter()
395 .rev()
396 .find_map(|entry| string_field(entry, "summary"))
397 })
398 .or_else(|| {
399 entries
400 .iter()
401 .find(|entry| is_visible_message(entry) && entry_type(entry) == Some("user"))
402 .and_then(|entry| entry.get("message"))
403 .and_then(|message| message.get("content"))
404 .map(content_to_string)
405 .map(truncate_title)
406 })
407}
408
409fn is_visible_message(entry: &serde_json::Map<String, serde_json::Value>) -> bool {
410 matches!(entry_type(entry), Some("user" | "assistant"))
411 && !bool_field(entry, "isMeta")
412 && !bool_field(entry, "isSidechain")
413 && !entry.contains_key("teamName")
414}
415
416fn entry_type(entry: &serde_json::Map<String, serde_json::Value>) -> Option<&str> {
417 entry.get("type").and_then(|value| value.as_str())
418}
419
420fn string_field(entry: &serde_json::Map<String, serde_json::Value>, field: &str) -> Option<String> {
421 entry
422 .get(field)
423 .and_then(|value| value.as_str())
424 .map(ToString::to_string)
425 .filter(|value| !value.is_empty())
426}
427
428fn bool_field(entry: &serde_json::Map<String, serde_json::Value>, field: &str) -> bool {
429 entry
430 .get(field)
431 .and_then(|value| value.as_bool())
432 .unwrap_or(false)
433}
434
435fn sanitize_tag(tag: &str) -> Result<String> {
436 let sanitized = tag
437 .chars()
438 .filter(|ch| !ch.is_control())
439 .collect::<String>()
440 .trim()
441 .to_string();
442 if sanitized.is_empty() {
443 Err(ClaudeSDKError::Session(
444 "tag must be non-empty (use None to clear)".to_string(),
445 ))
446 } else {
447 Ok(sanitized)
448 }
449}
450
451fn content_to_string(value: &serde_json::Value) -> String {
452 match value {
453 serde_json::Value::String(text) => text.clone(),
454 serde_json::Value::Array(blocks) => blocks
455 .iter()
456 .filter_map(|block| {
457 if block.get("type").and_then(|value| value.as_str()) == Some("text") {
458 block
459 .get("text")
460 .and_then(|value| value.as_str())
461 .map(ToString::to_string)
462 } else {
463 None
464 }
465 })
466 .collect::<Vec<_>>()
467 .join("\n"),
468 other => other.to_string(),
469 }
470}
471
472fn truncate_title(value: String) -> String {
473 let normalized = value.replace('\n', " ").trim().to_string();
474 if normalized.chars().count() <= 200 {
475 normalized
476 } else {
477 format!("{}...", normalized.chars().take(200).collect::<String>())
478 }
479}
480
481fn session_id_from_jsonl_path(path: &Path) -> Option<String> {
482 if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
483 return None;
484 }
485 let session_id = path.file_stem()?.to_str()?;
486 uuid::Uuid::parse_str(session_id).ok()?;
487 Some(session_id.to_string())
488}
489
490fn validate_session_id(session_id: &str) -> Result<()> {
491 uuid::Uuid::parse_str(session_id)
492 .map(|_| ())
493 .map_err(|_| ClaudeSDKError::Session(format!("Invalid session_id: {session_id}")))
494}