Skip to main content

claude_code_sdk_rust/sessions/
import.rs

1use 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}