claude_code_sdk_rust/sessions/
local_fork.rs1use 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}