claude_code_sdk_rust/sessions/
import.rs1use crate::error::{ClaudeSDKError, Result};
2use crate::session_store::{
3 project_key_for_directory, SessionKey, SessionStoreEntry, SessionStoreHandle,
4};
5use std::path::{Path, PathBuf};
6use tokio::io::{AsyncBufReadExt, BufReader};
7
8const MAX_PENDING_ENTRIES: usize = 500;
9const MAX_PENDING_BYTES: usize = 1 << 20;
10
11#[derive(Debug, Clone)]
12pub struct ImportSessionOptions {
13 pub directory: Option<String>,
14 pub include_subagents: bool,
15 pub batch_size: usize,
16}
17
18impl Default for ImportSessionOptions {
19 fn default() -> Self {
20 Self {
21 directory: None,
22 include_subagents: true,
23 batch_size: MAX_PENDING_ENTRIES,
24 }
25 }
26}
27
28pub async fn import_session_to_store(
29 session_id: &str,
30 store: &SessionStoreHandle,
31 options: ImportSessionOptions,
32) -> Result<()> {
33 validate_uuid(session_id)?;
34 let resolved = resolve_session_file_path(session_id, options.directory.as_deref())
35 .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))?;
36 let project_key = resolved
37 .parent()
38 .and_then(|path| path.file_name())
39 .and_then(|name| name.to_str())
40 .ok_or_else(|| ClaudeSDKError::Session("session path has no project key".to_string()))?
41 .to_string();
42 let batch_size = if options.batch_size == 0 {
43 MAX_PENDING_ENTRIES
44 } else {
45 options.batch_size
46 };
47
48 let main_key = SessionKey {
49 project_key: project_key.clone(),
50 session_id: session_id.to_string(),
51 subpath: None,
52 };
53 append_jsonl_file_in_batches(&resolved, main_key, store, batch_size).await?;
54
55 if options.include_subagents {
56 import_subagents(&resolved, &project_key, session_id, store, batch_size).await?;
57 }
58 Ok(())
59}
60
61async fn import_subagents(
62 session_file: &Path,
63 project_key: &str,
64 session_id: &str,
65 store: &SessionStoreHandle,
66 batch_size: usize,
67) -> Result<()> {
68 let session_dir = session_file.with_extension("");
69 let subagents_dir = session_dir.join("subagents");
70 for file_path in collect_jsonl_files(&subagents_dir) {
71 let subpath = subpath_for_file(&session_dir, &file_path)?;
72 let key = SessionKey {
73 project_key: project_key.to_string(),
74 session_id: session_id.to_string(),
75 subpath: Some(subpath),
76 };
77 append_jsonl_file_in_batches(&file_path, key.clone(), store, batch_size).await?;
78 if let Some(meta) = read_meta_sidecar(&file_path).await? {
79 store.append(key, vec![meta]).await?;
80 }
81 }
82 Ok(())
83}
84
85async fn append_jsonl_file_in_batches(
86 file_path: &Path,
87 key: SessionKey,
88 store: &SessionStoreHandle,
89 batch_size: usize,
90) -> Result<()> {
91 let file = tokio::fs::File::open(file_path).await?;
92 let mut lines = BufReader::new(file).lines();
93 let mut batch = Vec::new();
94 let mut nbytes = 0usize;
95
96 while let Some(line) = lines.next_line().await? {
97 if line.is_empty() {
98 continue;
99 }
100 nbytes += line.len();
101 let entry = serde_json::from_str::<SessionStoreEntry>(&line)?;
102 batch.push(entry);
103 if batch.len() >= batch_size || nbytes >= MAX_PENDING_BYTES {
104 store
105 .append(key.clone(), std::mem::take(&mut batch))
106 .await?;
107 nbytes = 0;
108 }
109 }
110 if !batch.is_empty() {
111 store.append(key, batch).await?;
112 }
113 Ok(())
114}
115
116async fn read_meta_sidecar(file_path: &Path) -> Result<Option<SessionStoreEntry>> {
117 let meta_path = file_path.with_file_name(format!(
118 "{}.meta.json",
119 file_path
120 .file_stem()
121 .and_then(|stem| stem.to_str())
122 .unwrap_or_default()
123 ));
124 let content = match tokio::fs::read_to_string(&meta_path).await {
125 Ok(content) => content,
126 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
127 Err(error) => return Err(error.into()),
128 };
129 let mut meta = serde_json::from_str::<SessionStoreEntry>(&content)?;
130 let mut entry = SessionStoreEntry::new();
131 entry.insert(
132 "type".to_string(),
133 serde_json::Value::String("agent_metadata".to_string()),
134 );
135 entry.append(&mut meta);
136 Ok(Some(entry))
137}
138
139fn resolve_session_file_path(session_id: &str, directory: Option<&str>) -> Option<PathBuf> {
140 let file_name = format!("{session_id}.jsonl");
141 if let Some(directory) = directory {
142 let canonical = canonicalize_path(Path::new(directory));
143 if let Some(found) =
144 find_project_dir(&canonical).and_then(|dir| stat_candidate(&dir, &file_name))
145 {
146 return Some(found);
147 }
148 return None;
149 }
150
151 let projects_dir = projects_dir();
152 let entries = std::fs::read_dir(projects_dir).ok()?;
153 for entry in entries.flatten() {
154 if entry.file_type().ok().is_some_and(|ty| ty.is_dir()) {
155 if let Some(found) = stat_candidate(&entry.path(), &file_name) {
156 return Some(found);
157 }
158 }
159 }
160 None
161}
162
163fn find_project_dir(project_path: &Path) -> Option<PathBuf> {
164 let exact = projects_dir().join(project_key_for_directory(Some(project_path)));
165 if exact.is_dir() {
166 Some(exact)
167 } else {
168 None
169 }
170}
171
172fn stat_candidate(project_dir: &Path, file_name: &str) -> Option<PathBuf> {
173 let candidate = project_dir.join(file_name);
174 match std::fs::metadata(&candidate) {
175 Ok(metadata) if metadata.is_file() && metadata.len() > 0 => Some(candidate),
176 _ => None,
177 }
178}
179
180fn collect_jsonl_files(base_dir: &Path) -> Vec<PathBuf> {
181 let mut output = Vec::new();
182 collect_jsonl_files_inner(base_dir, &mut output);
183 output
184}
185
186fn collect_jsonl_files_inner(base_dir: &Path, output: &mut Vec<PathBuf>) {
187 let Ok(entries) = std::fs::read_dir(base_dir) else {
188 return;
189 };
190 let mut entries = entries.flatten().collect::<Vec<_>>();
191 entries.sort_by_key(|entry| entry.file_name());
192 for entry in entries {
193 let path = entry.path();
194 if path.is_dir() {
195 collect_jsonl_files_inner(&path, output);
196 } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
197 output.push(path);
198 }
199 }
200}
201
202fn subpath_for_file(session_dir: &Path, file_path: &Path) -> Result<String> {
203 let relative = file_path.strip_prefix(session_dir).map_err(|_| {
204 ClaudeSDKError::Session("subagent path escaped session directory".to_string())
205 })?;
206 let mut parts = relative
207 .components()
208 .filter_map(|component| match component {
209 std::path::Component::Normal(value) => Some(value.to_string_lossy().to_string()),
210 _ => None,
211 })
212 .collect::<Vec<_>>();
213 if let Some(last) = parts.last_mut() {
214 if let Some(stripped) = last.strip_suffix(".jsonl") {
215 *last = stripped.to_string();
216 }
217 }
218 Ok(parts.join("/"))
219}
220
221fn projects_dir() -> PathBuf {
222 std::env::var_os("CLAUDE_CONFIG_DIR")
223 .map(PathBuf::from)
224 .or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
225 .unwrap_or_else(|| PathBuf::from(".claude"))
226 .join("projects")
227}
228
229fn canonicalize_path(path: &Path) -> PathBuf {
230 let absolute = if path.is_absolute() {
231 path.to_path_buf()
232 } else {
233 std::env::current_dir()
234 .unwrap_or_else(|_| PathBuf::from("."))
235 .join(path)
236 };
237 std::fs::canonicalize(&absolute).unwrap_or(absolute)
238}
239
240fn validate_uuid(session_id: &str) -> Result<()> {
241 uuid::Uuid::parse_str(session_id)
242 .map(|_| ())
243 .map_err(|_| ClaudeSDKError::Session(format!("Invalid session_id: {session_id}")))
244}