1use crate::errors::{Result, SdkError};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SessionInfo {
11 pub session_id: String,
13 #[serde(default)]
15 pub summary: String,
16 #[serde(default)]
18 pub last_modified: i64,
19 #[serde(default)]
21 pub file_size: u64,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub custom_title: Option<String>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub first_prompt: Option<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub git_branch: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub cwd: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SessionMessage {
39 #[serde(rename = "type")]
41 pub msg_type: String,
42 #[serde(default)]
44 pub uuid: String,
45 #[serde(default)]
47 pub session_id: String,
48 pub message: serde_json::Value,
50}
51
52fn sanitize_unicode(input: &str) -> String {
54 input
55 .chars()
56 .filter(|c| {
57 !matches!(
58 c,
59 '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{00AD}'
60 )
61 })
62 .collect()
63}
64
65pub async fn list_sessions(
72 directory: Option<&str>,
73 limit: Option<usize>,
74 include_worktrees: bool,
75) -> Result<Vec<SessionInfo>> {
76 let cli_path = crate::transport::subprocess::find_claude_cli()?;
77 let mut cmd = tokio::process::Command::new(&cli_path);
78
79 cmd.arg("sessions").arg("list").arg("--json");
80
81 if let Some(dir) = directory {
82 cmd.arg("--directory").arg(dir);
83 }
84
85 if let Some(limit) = limit {
86 cmd.arg("--limit").arg(limit.to_string());
87 }
88
89 if !include_worktrees {
90 cmd.arg("--no-worktrees");
91 }
92
93 let output = cmd.output().await.map_err(SdkError::ProcessError)?;
94
95 if !output.status.success() {
96 let stderr = String::from_utf8_lossy(&output.stderr);
97 return Err(SdkError::ConnectionError(format!(
98 "Failed to list sessions: {stderr}"
99 )));
100 }
101
102 let stdout = String::from_utf8_lossy(&output.stdout);
103 let sanitized = sanitize_unicode(&stdout);
104
105 serde_json::from_str(&sanitized).map_err(|e| {
106 SdkError::parse_error(
107 format!("Failed to parse session list: {e}"),
108 sanitized,
109 )
110 })
111}
112
113pub async fn get_session_messages(
121 session_id: &str,
122 directory: Option<&str>,
123 limit: Option<usize>,
124 offset: usize,
125) -> Result<Vec<SessionMessage>> {
126 let cli_path = crate::transport::subprocess::find_claude_cli()?;
127 let mut cmd = tokio::process::Command::new(&cli_path);
128
129 cmd.arg("sessions")
130 .arg("messages")
131 .arg("--session-id")
132 .arg(session_id)
133 .arg("--json");
134
135 if let Some(dir) = directory {
136 cmd.arg("--directory").arg(dir);
137 }
138
139 if let Some(limit) = limit {
140 cmd.arg("--limit").arg(limit.to_string());
141 }
142
143 if offset > 0 {
144 cmd.arg("--offset").arg(offset.to_string());
145 }
146
147 let output = cmd.output().await.map_err(SdkError::ProcessError)?;
148
149 if !output.status.success() {
150 let stderr = String::from_utf8_lossy(&output.stderr);
151 return Err(SdkError::ConnectionError(format!(
152 "Failed to get session messages: {stderr}"
153 )));
154 }
155
156 let stdout = String::from_utf8_lossy(&output.stdout);
157 let sanitized = sanitize_unicode(&stdout);
158
159 serde_json::from_str(&sanitized).map_err(|e| {
160 SdkError::parse_error(
161 format!("Failed to parse session messages: {e}"),
162 sanitized,
163 )
164 })
165}
166
167pub async fn rename_session(session_id: &str, title: &str) -> Result<()> {
173 let cli_path = crate::transport::subprocess::find_claude_cli()?;
174 let mut cmd = tokio::process::Command::new(&cli_path);
175
176 cmd.arg("sessions")
177 .arg("rename")
178 .arg("--session-id")
179 .arg(session_id)
180 .arg("--title")
181 .arg(title);
182
183 let output = cmd.output().await.map_err(SdkError::ProcessError)?;
184
185 if !output.status.success() {
186 let stderr = String::from_utf8_lossy(&output.stderr);
187 return Err(SdkError::ConnectionError(format!(
188 "Failed to rename session: {stderr}"
189 )));
190 }
191
192 Ok(())
193}
194
195pub async fn tag_session(session_id: &str, tag: Option<&str>) -> Result<()> {
201 let cli_path = crate::transport::subprocess::find_claude_cli()?;
202 let mut cmd = tokio::process::Command::new(&cli_path);
203
204 cmd.arg("sessions")
205 .arg("tag")
206 .arg("--session-id")
207 .arg(session_id);
208
209 if let Some(tag) = tag {
210 cmd.arg("--tag").arg(tag);
211 } else {
212 cmd.arg("--clear");
213 }
214
215 let output = cmd.output().await.map_err(SdkError::ProcessError)?;
216
217 if !output.status.success() {
218 let stderr = String::from_utf8_lossy(&output.stderr);
219 return Err(SdkError::ConnectionError(format!(
220 "Failed to tag session: {stderr}"
221 )));
222 }
223
224 Ok(())
225}
226
227pub async fn delete_session(session_id: &str, directory: Option<&str>) -> Result<()> {
233 let cli_path = crate::transport::subprocess::find_claude_cli()?;
234 let mut cmd = tokio::process::Command::new(&cli_path);
235
236 cmd.arg("sessions")
237 .arg("delete")
238 .arg("--session-id")
239 .arg(session_id);
240
241 if let Some(dir) = directory {
242 cmd.arg("--directory").arg(dir);
243 }
244
245 let output = cmd.output().await.map_err(SdkError::ProcessError)?;
246
247 if !output.status.success() {
248 let stderr = String::from_utf8_lossy(&output.stderr);
249 return Err(SdkError::ConnectionError(format!(
250 "Failed to delete session: {stderr}"
251 )));
252 }
253
254 Ok(())
255}
256
257pub async fn fork_session(
263 session_id: &str,
264 directory: Option<&str>,
265) -> Result<crate::types::ForkSessionResult> {
266 let cli_path = crate::transport::subprocess::find_claude_cli()?;
267 let mut cmd = tokio::process::Command::new(&cli_path);
268
269 cmd.arg("sessions")
270 .arg("fork")
271 .arg("--session-id")
272 .arg(session_id)
273 .arg("--json");
274
275 if let Some(dir) = directory {
276 cmd.arg("--directory").arg(dir);
277 }
278
279 let output = cmd.output().await.map_err(SdkError::ProcessError)?;
280
281 if !output.status.success() {
282 let stderr = String::from_utf8_lossy(&output.stderr);
283 return Err(SdkError::ConnectionError(format!(
284 "Failed to fork session: {stderr}"
285 )));
286 }
287
288 let stdout = String::from_utf8_lossy(&output.stdout);
289 let sanitized = sanitize_unicode(&stdout);
290
291 serde_json::from_str(&sanitized).map_err(|e| {
292 SdkError::parse_error(
293 format!("Failed to parse fork session result: {e}"),
294 sanitized,
295 )
296 })
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn test_sanitize_unicode() {
305 assert_eq!(sanitize_unicode("hello\u{200B}world"), "helloworld");
306 assert_eq!(sanitize_unicode("no\u{FEFF}bom"), "nobom");
307 assert_eq!(sanitize_unicode("clean text"), "clean text");
308 assert_eq!(sanitize_unicode("\u{200C}\u{200D}"), "");
309 assert_eq!(sanitize_unicode("soft\u{00AD}hyphen"), "softhyphen");
310 }
311
312 #[test]
313 fn test_session_info_deserialize() {
314 let json = serde_json::json!({
315 "session_id": "sess-123",
316 "summary": "Test session",
317 "last_modified": 1710000000000_i64,
318 "file_size": 4096,
319 "custom_title": "My Session",
320 "first_prompt": "Hello",
321 "git_branch": "main",
322 "cwd": "/tmp"
323 });
324
325 let info: SessionInfo = serde_json::from_value(json).unwrap();
326 assert_eq!(info.session_id, "sess-123");
327 assert_eq!(info.custom_title.as_deref(), Some("My Session"));
328 assert_eq!(info.git_branch.as_deref(), Some("main"));
329 }
330
331 #[test]
332 fn test_session_info_minimal() {
333 let json = serde_json::json!({
334 "session_id": "sess-min"
335 });
336
337 let info: SessionInfo = serde_json::from_value(json).unwrap();
338 assert_eq!(info.session_id, "sess-min");
339 assert_eq!(info.summary, "");
340 assert_eq!(info.last_modified, 0);
341 assert!(info.custom_title.is_none());
342 }
343
344 #[test]
345 fn test_session_message_deserialize() {
346 let json = serde_json::json!({
347 "type": "user",
348 "uuid": "uuid-1",
349 "session_id": "sess-1",
350 "message": {"content": "hello"}
351 });
352
353 let msg: SessionMessage = serde_json::from_value(json).unwrap();
354 assert_eq!(msg.msg_type, "user");
355 assert_eq!(msg.uuid, "uuid-1");
356 assert_eq!(msg.message["content"], "hello");
357 }
358}