Skip to main content

ccs/search/
message.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4// Re-export SessionSource from the shared session module
5pub use crate::session::SessionSource;
6
7/// Represents a message from Claude Code JSONL session
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct Message {
10    pub session_id: String,
11    pub role: String,
12    pub content: String,
13    pub timestamp: DateTime<Utc>,
14    pub branch: Option<String>,
15    pub line_number: usize,
16    pub uuid: Option<String>,
17    pub parent_uuid: Option<String>,
18}
19
20impl Message {
21    /// Parse a JSONL line into a Message
22    /// Supports both Claude Code CLI format (sessionId, timestamp) and
23    /// Claude Desktop format (session_id, _audit_timestamp)
24    pub fn from_jsonl(line: &str, line_number: usize) -> Option<Self> {
25        use crate::session;
26
27        let json: serde_json::Value = serde_json::from_str(line).ok()?;
28        if session::is_synthetic_linear_record(&json) {
29            return None;
30        }
31
32        // Skip non-message types (summary, etc.)
33        let msg_type = session::extract_record_type(&json)?;
34        if msg_type != "user" && msg_type != "assistant" {
35            return None;
36        }
37
38        let message = json.get("message")?;
39        let role = message.get("role")?.as_str()?.to_string();
40        let content_raw = message.get("content")?;
41        let content = Self::extract_content(content_raw);
42
43        // Skip empty content
44        if content.trim().is_empty() {
45            return None;
46        }
47
48        let session_id = session::extract_session_id(&json)?;
49        let timestamp = session::extract_timestamp(&json)?;
50
51        // Branch is CLI-only, Desktop doesn't have it
52        let branch = json
53            .get("branch")
54            .or_else(|| json.get("gitBranch"))
55            .and_then(|b| b.as_str())
56            .filter(|s| !s.is_empty())
57            .map(|s| s.to_string());
58
59        let uuid = session::extract_uuid(&json);
60        let parent_uuid = session::extract_parent_uuid(&json);
61
62        Some(Message {
63            session_id,
64            role,
65            content,
66            timestamp,
67            branch,
68            line_number,
69            uuid,
70            parent_uuid,
71        })
72    }
73
74    /// Extract text content from message content blocks
75    /// Handles both array format [{"type":"text","text":"..."}] and plain string format
76    pub fn extract_content(raw: &serde_json::Value) -> String {
77        // Handle plain string content (e.g., user messages with "content": "text")
78        if let Some(s) = raw.as_str() {
79            return s.to_string();
80        }
81
82        let mut parts: Vec<String> = Vec::new();
83
84        if let Some(arr) = raw.as_array() {
85            for item in arr {
86                let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
87
88                match item_type {
89                    "text" => {
90                        if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
91                            parts.push(text.to_string());
92                        }
93                    }
94                    "tool_use" => {
95                        // Include tool input for searchability
96                        if let Some(input) = item.get("input") {
97                            if let Ok(json_str) = serde_json::to_string(input) {
98                                parts.push(json_str);
99                            }
100                        }
101                    }
102                    "tool_result" => {
103                        if let Some(content) = item.get("content") {
104                            if let Some(s) = content.as_str() {
105                                parts.push(s.to_string());
106                            } else if let Ok(json_str) = serde_json::to_string(content) {
107                                parts.push(json_str);
108                            }
109                        }
110                    }
111                    _ => {}
112                }
113            }
114        }
115
116        parts.join("\n")
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_parse_user_message() {
126        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello Claude"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
127
128        let msg = Message::from_jsonl(jsonl, 1).expect("Should parse user message");
129
130        assert_eq!(msg.session_id, "abc123");
131        assert_eq!(msg.role, "user");
132        assert_eq!(msg.content, "Hello Claude");
133        assert_eq!(msg.line_number, 1);
134    }
135
136    #[test]
137    fn test_parse_assistant_message() {
138        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello! How can I help?"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:01:00Z"}"#;
139
140        let msg = Message::from_jsonl(jsonl, 2).expect("Should parse assistant message");
141
142        assert_eq!(msg.role, "assistant");
143        assert_eq!(msg.content, "Hello! How can I help?");
144    }
145
146    #[test]
147    fn test_parse_message_with_branch() {
148        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Fix bug"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z","cwd":"/projects/myapp","branch":"feature/fix-bug"}"#;
149
150        let msg = Message::from_jsonl(jsonl, 1).expect("Should parse message with branch");
151
152        assert_eq!(msg.branch, Some("feature/fix-bug".to_string()));
153    }
154
155    #[test]
156    fn test_parse_message_multiple_text_blocks() {
157        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Part 1"},{"type":"text","text":"Part 2"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
158
159        let msg = Message::from_jsonl(jsonl, 1).expect("Should parse multiple text blocks");
160
161        assert_eq!(msg.content, "Part 1\nPart 2");
162    }
163
164    #[test]
165    fn test_parse_tool_use_message() {
166        let jsonl = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/tmp/test.txt"}}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
167
168        let msg = Message::from_jsonl(jsonl, 1).expect("Should parse tool_use message");
169
170        assert!(msg.content.contains("file_path"));
171        assert!(msg.content.contains("/tmp/test.txt"));
172    }
173
174    #[test]
175    fn test_skip_summary_type() {
176        let jsonl = r#"{"type":"summary","summary":"Session summary text","sessionId":"abc123"}"#;
177
178        let msg = Message::from_jsonl(jsonl, 1);
179
180        assert!(msg.is_none(), "Should skip summary type messages");
181    }
182
183    #[test]
184    fn test_skip_invalid_json() {
185        let jsonl = "not valid json {{{";
186
187        let msg = Message::from_jsonl(jsonl, 1);
188
189        assert!(msg.is_none(), "Should skip invalid JSON");
190    }
191
192    #[test]
193    fn test_extract_content_from_text_block() {
194        let raw: serde_json::Value = serde_json::json!([
195            {"type": "text", "text": "Hello world"}
196        ]);
197
198        let content = Message::extract_content(&raw);
199
200        assert_eq!(content, "Hello world");
201    }
202
203    #[test]
204    fn test_extract_content_from_tool_result() {
205        let raw: serde_json::Value = serde_json::json!([
206            {"type": "tool_result", "content": "File contents here"}
207        ]);
208
209        let content = Message::extract_content(&raw);
210
211        assert!(content.contains("File contents here"));
212    }
213
214    #[test]
215    fn test_parse_desktop_format_message() {
216        // Desktop format uses session_id (underscore) and _audit_timestamp
217        let jsonl = r#"{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","id":"msg_01Q4WpB2jNfsHuijFwLGLFwN","type":"message","role":"assistant","content":[{"type":"text","text":"I'd love to help you analyze how you're spending your time!"}],"stop_reason":null,"stop_sequence":null},"parent_tool_use_id":null,"session_id":"0c2f5015-9457-491f-8f35-218d6c34ff68","uuid":"aa419e76-930a-4970-85c3-b5f03e85f6e0","_audit_timestamp":"2026-01-13T13:31:31.268Z"}"#;
218
219        let msg = Message::from_jsonl(jsonl, 1).expect("Should parse Desktop format message");
220
221        assert_eq!(msg.session_id, "0c2f5015-9457-491f-8f35-218d6c34ff68");
222        assert_eq!(msg.role, "assistant");
223        assert!(msg.content.contains("I'd love to help you analyze"));
224        assert!(msg.branch.is_none()); // Desktop doesn't have branch
225        assert_eq!(
226            msg.uuid,
227            Some("aa419e76-930a-4970-85c3-b5f03e85f6e0".to_string())
228        );
229    }
230
231    #[test]
232    fn test_parse_desktop_user_message() {
233        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello from Desktop"}]},"session_id":"desktop-session-123","_audit_timestamp":"2026-01-13T10:00:00.000Z"}"#;
234
235        let msg = Message::from_jsonl(jsonl, 1).expect("Should parse Desktop user message");
236
237        assert_eq!(msg.session_id, "desktop-session-123");
238        assert_eq!(msg.role, "user");
239        assert_eq!(msg.content, "Hello from Desktop");
240    }
241
242    #[test]
243    fn test_parse_string_content() {
244        // Some user messages have content as a plain string instead of array
245        let jsonl = r#"{"type":"user","message":{"role":"user","content":"Hello plain string"},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
246
247        let msg = Message::from_jsonl(jsonl, 1).expect("Should parse plain string content");
248
249        assert_eq!(msg.content, "Hello plain string");
250        assert_eq!(msg.role, "user");
251    }
252
253    #[test]
254    fn test_parse_uuid_and_parent_uuid() {
255        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z","uuid":"uuid-111","parentUuid":"uuid-000"}"#;
256
257        let msg = Message::from_jsonl(jsonl, 1).expect("Should parse message with uuid");
258
259        assert_eq!(msg.uuid, Some("uuid-111".to_string()));
260        assert_eq!(msg.parent_uuid, Some("uuid-000".to_string()));
261    }
262
263    #[test]
264    fn test_parse_message_without_uuid() {
265        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z"}"#;
266
267        let msg = Message::from_jsonl(jsonl, 1).expect("Should parse message without uuid");
268
269        assert_eq!(msg.uuid, None);
270        assert_eq!(msg.parent_uuid, None);
271    }
272
273    #[test]
274    fn test_skip_synthetic_linear_message() {
275        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hidden synthetic session"}]},"sessionId":"abc123","timestamp":"2025-01-09T10:00:00Z","ccsSyntheticLinear":true}"#;
276
277        let msg = Message::from_jsonl(jsonl, 1);
278
279        assert!(
280            msg.is_none(),
281            "Synthetic linear sessions should not be indexed"
282        );
283    }
284
285    #[test]
286    fn test_extract_content_string() {
287        let raw: serde_json::Value = serde_json::json!("plain text content");
288
289        let content = Message::extract_content(&raw);
290
291        assert_eq!(content, "plain text content");
292    }
293}