Skip to main content

cc_sdk/
sessions.rs

1//! Session history API
2//!
3//! Provides functions to list, query, and manage Claude Code conversation sessions.
4
5use crate::errors::{Result, SdkError};
6use serde::{Deserialize, Serialize};
7
8/// Information about a stored session
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SessionInfo {
11    /// Session ID
12    pub session_id: String,
13    /// Session summary text
14    #[serde(default)]
15    pub summary: String,
16    /// Last modified timestamp (ms since epoch)
17    #[serde(default)]
18    pub last_modified: i64,
19    /// File size in bytes
20    #[serde(default)]
21    pub file_size: u64,
22    /// Custom title set by user
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub custom_title: Option<String>,
25    /// First prompt in the session
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub first_prompt: Option<String>,
28    /// Git branch when session was created
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub git_branch: Option<String>,
31    /// Working directory
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub cwd: Option<String>,
34}
35
36/// A single message from a session's history
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SessionMessage {
39    /// Message type (user, assistant, system, result)
40    #[serde(rename = "type")]
41    pub msg_type: String,
42    /// Unique message ID
43    #[serde(default)]
44    pub uuid: String,
45    /// Session ID
46    #[serde(default)]
47    pub session_id: String,
48    /// Full message data
49    pub message: serde_json::Value,
50}
51
52/// Sanitize Unicode by removing zero-width and invisible characters
53fn 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
65/// List available sessions
66///
67/// # Arguments
68/// * `directory` - Optional working directory filter
69/// * `limit` - Maximum number of sessions to return
70/// * `include_worktrees` - Whether to include worktree sessions (default: true)
71pub 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
113/// Get messages from a specific session
114///
115/// # Arguments
116/// * `session_id` - The session ID to query
117/// * `directory` - Optional working directory context
118/// * `limit` - Maximum number of messages to return
119/// * `offset` - Number of messages to skip from the beginning
120pub 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
167/// Rename a session with a custom title
168///
169/// # Arguments
170/// * `session_id` - The session ID to rename
171/// * `title` - The new title for the session
172pub 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
195/// Tag a session with a label
196///
197/// # Arguments
198/// * `session_id` - The session ID to tag
199/// * `tag` - The tag to apply, or None to clear the tag
200pub 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
227/// Delete a session
228///
229/// # Arguments
230/// * `session_id` - The session ID to delete
231/// * `directory` - Optional working directory context
232pub 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
257/// Fork a session, creating a new session branching from the original
258///
259/// # Arguments
260/// * `session_id` - The session ID to fork from
261/// * `directory` - Optional working directory context
262pub 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}