Skip to main content

claude_code_sdk_rust/sessions/
local_fork.rs

1use crate::error::{ClaudeSDKError, Result};
2use crate::session_store::project_key_for_directory;
3use serde_json::{Map, Value};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct LocalForkSessionResult {
9    pub session_id: String,
10}
11
12pub async fn fork_session(
13    session_id: &str,
14    directory: Option<&str>,
15    up_to_message_id: Option<&str>,
16    title: Option<&str>,
17) -> Result<LocalForkSessionResult> {
18    validate_uuid("session_id", session_id)?;
19    if let Some(up_to_message_id) = up_to_message_id {
20        validate_uuid("up_to_message_id", up_to_message_id)?;
21    }
22
23    let source_path = resolve_session_file(session_id, directory)
24        .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))?;
25    let entries = read_jsonl_entries(&source_path).await?;
26    let forked_entries = fork_entries(entries, session_id, up_to_message_id, title)?;
27    let forked_session_id = forked_entries
28        .first()
29        .and_then(|entry| entry.get("sessionId").or_else(|| entry.get("session_id")))
30        .and_then(|value| value.as_str())
31        .ok_or_else(|| ClaudeSDKError::Session("fork produced no session id".to_string()))?
32        .to_string();
33    let fork_path = source_path.with_file_name(format!("{forked_session_id}.jsonl"));
34    write_jsonl_entries(&fork_path, &forked_entries).await?;
35    Ok(LocalForkSessionResult {
36        session_id: forked_session_id,
37    })
38}
39
40fn fork_entries(
41    entries: Vec<Map<String, Value>>,
42    source_session_id: &str,
43    up_to_message_id: Option<&str>,
44    title: Option<&str>,
45) -> Result<Vec<Map<String, Value>>> {
46    let selected = select_entries(entries, up_to_message_id)?;
47    if selected.is_empty() {
48        return Err(ClaudeSDKError::Session(
49            "session has no messages to fork".to_string(),
50        ));
51    }
52
53    let forked_session_id = uuid::Uuid::new_v4().to_string();
54    let uuid_map = selected
55        .iter()
56        .filter_map(|entry| entry.get("uuid").and_then(|value| value.as_str()))
57        .map(|old| (old.to_string(), uuid::Uuid::new_v4().to_string()))
58        .collect::<HashMap<_, _>>();
59
60    let mut forked = selected
61        .into_iter()
62        .map(|entry| remap_entry(entry, source_session_id, &forked_session_id, &uuid_map))
63        .collect::<Vec<_>>();
64    if let Some(title) = title.map(str::trim).filter(|title| !title.is_empty()) {
65        forked.push(custom_title_entry(&forked_session_id, title));
66    }
67    Ok(forked)
68}
69
70fn select_entries(
71    entries: Vec<Map<String, Value>>,
72    up_to_message_id: Option<&str>,
73) -> Result<Vec<Map<String, Value>>> {
74    let Some(up_to_message_id) = up_to_message_id else {
75        return Ok(entries);
76    };
77    let mut selected = Vec::new();
78    let mut found = false;
79    for entry in entries {
80        let matches_target =
81            entry.get("uuid").and_then(|value| value.as_str()) == Some(up_to_message_id);
82        selected.push(entry);
83        if matches_target {
84            found = true;
85            break;
86        }
87    }
88    if found {
89        Ok(selected)
90    } else {
91        Err(ClaudeSDKError::Session(format!(
92            "Message {up_to_message_id} not found"
93        )))
94    }
95}
96
97fn remap_entry(
98    mut entry: Map<String, Value>,
99    source_session_id: &str,
100    forked_session_id: &str,
101    uuid_map: &HashMap<String, String>,
102) -> Map<String, Value> {
103    replace_uuid_field(&mut entry, "uuid", uuid_map);
104    replace_uuid_field(&mut entry, "parentUuid", uuid_map);
105    replace_uuid_field(&mut entry, "parent_uuid", uuid_map);
106    replace_uuid_field(&mut entry, "parent_tool_use_id", uuid_map);
107    entry.insert(
108        "sessionId".to_string(),
109        serde_json::json!(forked_session_id),
110    );
111    entry.insert(
112        "session_id".to_string(),
113        serde_json::json!(forked_session_id),
114    );
115    entry.insert(
116        "forkedFrom".to_string(),
117        serde_json::json!(source_session_id),
118    );
119    entry
120}
121
122fn replace_uuid_field(
123    entry: &mut Map<String, Value>,
124    field: &str,
125    uuid_map: &HashMap<String, String>,
126) {
127    let Some(old) = entry.get(field).and_then(|value| value.as_str()) else {
128        return;
129    };
130    if let Some(new) = uuid_map.get(old) {
131        entry.insert(field.to_string(), serde_json::json!(new));
132    }
133}
134
135fn custom_title_entry(session_id: &str, title: &str) -> Map<String, Value> {
136    let mut entry = Map::new();
137    entry.insert("type".to_string(), serde_json::json!("custom-title"));
138    entry.insert("customTitle".to_string(), serde_json::json!(title));
139    entry.insert("sessionId".to_string(), serde_json::json!(session_id));
140    entry.insert("session_id".to_string(), serde_json::json!(session_id));
141    entry.insert(
142        "uuid".to_string(),
143        serde_json::json!(uuid::Uuid::new_v4().to_string()),
144    );
145    entry.insert(
146        "timestamp".to_string(),
147        serde_json::json!(chrono::Utc::now().to_rfc3339()),
148    );
149    entry
150}
151
152fn resolve_session_file(session_id: &str, directory: Option<&str>) -> Option<PathBuf> {
153    let file_name = format!("{session_id}.jsonl");
154    for project in project_dirs(directory) {
155        let candidate = project.join(&file_name);
156        if candidate.is_file() {
157            return Some(candidate);
158        }
159    }
160    None
161}
162
163fn project_dirs(directory: Option<&str>) -> Vec<PathBuf> {
164    if let Some(directory) = directory {
165        let dir = projects_dir().join(project_key_for_directory(Some(Path::new(directory))));
166        return if dir.is_dir() { vec![dir] } else { Vec::new() };
167    }
168    let Ok(projects) = std::fs::read_dir(projects_dir()) else {
169        return Vec::new();
170    };
171    projects
172        .flatten()
173        .filter(|project| project.file_type().ok().is_some_and(|ty| ty.is_dir()))
174        .map(|project| project.path())
175        .collect()
176}
177
178fn projects_dir() -> PathBuf {
179    std::env::var_os("CLAUDE_CONFIG_DIR")
180        .map(PathBuf::from)
181        .or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
182        .unwrap_or_else(|| PathBuf::from(".claude"))
183        .join("projects")
184}
185
186async fn read_jsonl_entries(path: &Path) -> Result<Vec<Map<String, Value>>> {
187    let content = tokio::fs::read_to_string(path).await?;
188    Ok(content
189        .lines()
190        .filter_map(|line| serde_json::from_str::<Map<String, Value>>(line).ok())
191        .collect())
192}
193
194async fn write_jsonl_entries(path: &Path, entries: &[Map<String, Value>]) -> Result<()> {
195    use tokio::io::AsyncWriteExt;
196
197    let mut file = tokio::fs::OpenOptions::new()
198        .write(true)
199        .create_new(true)
200        .open(path)
201        .await?;
202    for entry in entries {
203        file.write_all(serde_json::to_string(entry)?.as_bytes())
204            .await?;
205        file.write_all(b"\n").await?;
206    }
207    Ok(())
208}
209
210fn validate_uuid(name: &str, value: &str) -> Result<()> {
211    uuid::Uuid::parse_str(value)
212        .map(|_| ())
213        .map_err(|_| ClaudeSDKError::Session(format!("Invalid {name}: {value}")))
214}