Skip to main content

smc/
models.rs

1/// Claude Code JSONL record types — deserialization only.
2///
3/// Claude Code stores conversations as JSONL in ~/.claude/projects/.
4/// Each line is one of these record types.
5use serde::Deserialize;
6
7// ── Top-level record ───────────────────────────────────────────────────────
8
9#[derive(Debug, Deserialize)]
10#[serde(tag = "type", rename_all = "kebab-case")]
11pub enum Record {
12    User(MessageRecord),
13    Assistant(MessageRecord),
14    System(MessageRecord),
15    FileHistorySnapshot(serde_json::Value),
16    Progress(serde_json::Value),
17    #[serde(other)]
18    Unknown,
19}
20
21impl Record {
22    pub fn as_message(&self) -> Option<&MessageRecord> {
23        match self {
24            Record::User(r) | Record::Assistant(r) | Record::System(r) => Some(r),
25            _ => None,
26        }
27    }
28
29    pub fn role(&self) -> &'static str {
30        match self {
31            Record::User(_) => "user",
32            Record::Assistant(_) => "assistant",
33            Record::System(_) => "system",
34            _ => "other",
35        }
36    }
37
38    pub fn is_message(&self) -> bool {
39        matches!(self, Record::User(_) | Record::Assistant(_) | Record::System(_))
40    }
41}
42
43// ── Message ────────────────────────────────────────────────────────────────
44
45#[derive(Debug, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct MessageRecord {
48    pub uuid: Option<String>,
49    pub parent_uuid: Option<serde_json::Value>,
50    pub session_id: Option<String>,
51    pub timestamp: Option<String>,
52    pub cwd: Option<String>,
53    pub git_branch: Option<String>,
54    pub version: Option<String>,
55    pub message: Message,
56}
57
58#[derive(Debug, Deserialize)]
59pub struct Message {
60    pub role: String,
61    pub content: MessageContent,
62}
63
64#[derive(Debug, Deserialize)]
65#[serde(untagged)]
66pub enum MessageContent {
67    Text(String),
68    Blocks(Vec<ContentBlock>),
69}
70
71#[derive(Debug, Deserialize)]
72#[serde(tag = "type", rename_all = "snake_case")]
73pub enum ContentBlock {
74    Text { text: String },
75    Thinking { thinking: String },
76    ToolUse {
77        id: Option<String>,
78        name: String,
79        input: serde_json::Value,
80    },
81    ToolResult {
82        tool_use_id: Option<String>,
83        content: Option<serde_json::Value>,
84    },
85    #[serde(other)]
86    Other,
87}
88
89// ── Content extraction ─────────────────────────────────────────────────────
90
91impl MessageRecord {
92    /// All text content (text blocks + thinking + tool use/results).
93    pub fn text_content(&self) -> String {
94        match &self.message.content {
95            MessageContent::Text(s) => s.clone(),
96            MessageContent::Blocks(blocks) => {
97                let mut parts = Vec::new();
98                for block in blocks {
99                    match block {
100                        ContentBlock::Text { text } => parts.push(text.as_str()),
101                        ContentBlock::Thinking { thinking } => parts.push(thinking.as_str()),
102                        ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } => {}
103                        ContentBlock::Other => {}
104                    }
105                }
106                parts.join("\n")
107            }
108        }
109    }
110
111    /// Text content excluding thinking blocks.
112    pub fn text_no_thinking(&self) -> String {
113        match &self.message.content {
114            MessageContent::Text(s) => s.clone(),
115            MessageContent::Blocks(blocks) => {
116                let mut parts = Vec::new();
117                for block in blocks {
118                    if let ContentBlock::Text { text } = block {
119                        parts.push(text.as_str());
120                    }
121                }
122                parts.join("\n")
123            }
124        }
125    }
126
127    /// Only thinking block content.
128    pub fn thinking_content(&self) -> String {
129        match &self.message.content {
130            MessageContent::Blocks(blocks) => {
131                let mut parts = Vec::new();
132                for block in blocks {
133                    if let ContentBlock::Thinking { thinking } = block {
134                        parts.push(thinking.as_str());
135                    }
136                }
137                parts.join("\n")
138            }
139            _ => String::new(),
140        }
141    }
142
143    /// Only tool input content (name + serialized input).
144    pub fn tool_input_content(&self) -> String {
145        match &self.message.content {
146            MessageContent::Blocks(blocks) => {
147                let mut parts = Vec::new();
148                for block in blocks {
149                    if let ContentBlock::ToolUse { name, input, .. } = block {
150                        parts.push(format!("[{}] {}", name, input));
151                    }
152                }
153                parts.join("\n")
154            }
155            _ => String::new(),
156        }
157    }
158
159    /// Names of tools called in this message.
160    pub fn tool_names(&self) -> Vec<&str> {
161        match &self.message.content {
162            MessageContent::Blocks(blocks) => blocks
163                .iter()
164                .filter_map(|b| match b {
165                    ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
166                    _ => None,
167                })
168                .collect(),
169            _ => vec![],
170        }
171    }
172
173    /// Check if any tool input/result references a file path (substring match).
174    pub fn touches_file(&self, path: &str) -> bool {
175        let path_lower = path.to_lowercase();
176        match &self.message.content {
177            MessageContent::Blocks(blocks) => blocks.iter().any(|block| match block {
178                ContentBlock::ToolUse { input, .. } => {
179                    input.to_string().to_lowercase().contains(&path_lower)
180                }
181                ContentBlock::ToolResult { content: Some(c), .. } => {
182                    c.to_string().to_lowercase().contains(&path_lower)
183                }
184                _ => false,
185            }),
186            _ => false,
187        }
188    }
189
190    /// Full content including tool calls/results (for search).
191    pub fn full_content(&self) -> String {
192        match &self.message.content {
193            MessageContent::Text(s) => s.clone(),
194            MessageContent::Blocks(blocks) => {
195                let mut parts = Vec::new();
196                for block in blocks {
197                    match block {
198                        ContentBlock::Text { text } => parts.push(text.clone()),
199                        ContentBlock::Thinking { thinking } => parts.push(thinking.clone()),
200                        ContentBlock::ToolUse { name, input, .. } => {
201                            parts.push(format!("[tool: {}] {}", name, input));
202                        }
203                        ContentBlock::ToolResult { content: Some(c), .. } => {
204                            parts.push(format!("[result] {}", c));
205                        }
206                        _ => {}
207                    }
208                }
209                parts.join("\n")
210            }
211        }
212    }
213}