Skip to main content

chasm_cli/
models.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Data models for VS Code chat sessions
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// VS Code workspace information
10#[derive(Debug, Clone)]
11pub struct Workspace {
12    /// Workspace hash (folder name in workspaceStorage)
13    pub hash: String,
14    /// Associated project path
15    pub project_path: Option<String>,
16    /// Full path to workspace directory
17    pub workspace_path: std::path::PathBuf,
18    /// Path to chatSessions directory
19    pub chat_sessions_path: std::path::PathBuf,
20    /// Number of chat session files
21    pub chat_session_count: usize,
22    /// Whether chatSessions directory exists
23    pub has_chat_sessions: bool,
24    /// Last modified timestamp
25    #[allow(dead_code)]
26    pub last_modified: Option<DateTime<Utc>>,
27}
28
29/// VS Code workspace.json structure
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct WorkspaceJson {
32    pub folder: Option<String>,
33}
34
35/// VS Code Chat Session (version 3 format)
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct ChatSession {
39    /// Session format version
40    #[serde(default = "default_version")]
41    pub version: u32,
42
43    /// Unique session identifier (may not be present in file, use filename)
44    #[serde(default)]
45    pub session_id: Option<String>,
46
47    /// Creation timestamp (milliseconds)
48    #[serde(default)]
49    pub creation_date: i64,
50
51    /// Last message timestamp (milliseconds)
52    #[serde(default)]
53    pub last_message_date: i64,
54
55    /// Whether this session was imported
56    #[serde(default)]
57    pub is_imported: bool,
58
59    /// Initial location (panel, terminal, notebook, editor)
60    #[serde(default = "default_location")]
61    pub initial_location: String,
62
63    /// Custom title set by user
64    #[serde(default)]
65    pub custom_title: Option<String>,
66
67    /// Requester username
68    #[serde(default)]
69    pub requester_username: Option<String>,
70
71    /// Requester avatar URI
72    #[serde(default)]
73    pub requester_avatar_icon_uri: Option<serde_json::Value>,
74
75    /// Responder username
76    #[serde(default)]
77    pub responder_username: Option<String>,
78
79    /// Responder avatar URI
80    #[serde(default)]
81    pub responder_avatar_icon_uri: Option<serde_json::Value>,
82
83    /// Chat requests/messages
84    #[serde(default)]
85    pub requests: Vec<ChatRequest>,
86}
87
88impl ChatSession {
89    /// Collect all text content from the session (user messages and responses)
90    pub fn collect_all_text(&self) -> String {
91        self.requests
92            .iter()
93            .flat_map(|req| {
94                let mut texts = Vec::new();
95                if let Some(msg) = &req.message {
96                    if let Some(text) = &msg.text {
97                        texts.push(text.as_str());
98                    }
99                }
100                if let Some(resp) = &req.response {
101                    if let Some(result) = resp.get("result").and_then(|v| v.as_str()) {
102                        texts.push(result);
103                    }
104                }
105                texts
106            })
107            .collect::<Vec<_>>()
108            .join(" ")
109    }
110
111    /// Get user message texts
112    pub fn user_messages(&self) -> Vec<&str> {
113        self.requests
114            .iter()
115            .filter_map(|req| req.message.as_ref().and_then(|m| m.text.as_deref()))
116            .collect()
117    }
118
119    /// Get assistant response texts
120    pub fn assistant_responses(&self) -> Vec<String> {
121        self.requests
122            .iter()
123            .filter_map(|req| {
124                req.response.as_ref().and_then(|r| {
125                    r.get("result")
126                        .and_then(|v| v.as_str())
127                        .map(|s| s.to_string())
128                })
129            })
130            .collect()
131    }
132}
133
134fn default_version() -> u32 {
135    3
136}
137
138fn default_location() -> String {
139    "panel".to_string()
140}
141
142/// A single chat request (message + response)
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct ChatRequest {
146    /// Request timestamp (milliseconds)
147    #[serde(default)]
148    pub timestamp: Option<i64>,
149
150    /// The user's message
151    #[serde(default)]
152    pub message: Option<ChatMessage>,
153
154    /// The AI's response (complex structure - use Value for flexibility)
155    #[serde(default)]
156    pub response: Option<serde_json::Value>,
157
158    /// Variable data (context, files, etc.)
159    #[serde(default)]
160    pub variable_data: Option<serde_json::Value>,
161
162    /// Request ID
163    #[serde(default)]
164    pub request_id: Option<String>,
165
166    /// Response ID
167    #[serde(default)]
168    pub response_id: Option<String>,
169
170    /// Model ID
171    #[serde(default)]
172    pub model_id: Option<String>,
173
174    /// Agent information
175    #[serde(default)]
176    pub agent: Option<serde_json::Value>,
177
178    /// Result metadata
179    #[serde(default)]
180    pub result: Option<serde_json::Value>,
181
182    /// Follow-up suggestions
183    #[serde(default)]
184    pub followups: Option<Vec<serde_json::Value>>,
185
186    /// Whether canceled
187    #[serde(default)]
188    pub is_canceled: Option<bool>,
189
190    /// Content references
191    #[serde(default)]
192    pub content_references: Option<Vec<serde_json::Value>>,
193
194    /// Code citations
195    #[serde(default)]
196    pub code_citations: Option<Vec<serde_json::Value>>,
197
198    /// Response markdown info
199    #[serde(default)]
200    pub response_markdown_info: Option<Vec<serde_json::Value>>,
201
202    /// Source session for merged requests
203    #[serde(rename = "_sourceSession", skip_serializing_if = "Option::is_none")]
204    pub source_session: Option<String>,
205}
206
207/// User message in a chat request
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct ChatMessage {
211    /// Message text
212    #[serde(alias = "content")]
213    pub text: Option<String>,
214
215    /// Message parts (for complex messages)
216    #[serde(default)]
217    pub parts: Option<Vec<serde_json::Value>>,
218}
219
220impl ChatMessage {
221    /// Get the text content of this message
222    pub fn get_text(&self) -> String {
223        self.text.clone().unwrap_or_default()
224    }
225}
226
227/// AI response in a chat request
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(rename_all = "camelCase")]
230#[allow(dead_code)]
231pub struct ChatResponse {
232    /// Response text
233    #[serde(alias = "content")]
234    pub text: Option<String>,
235
236    /// Response parts
237    #[serde(default)]
238    pub parts: Option<Vec<serde_json::Value>>,
239
240    /// Result metadata
241    #[serde(default)]
242    pub result: Option<serde_json::Value>,
243}
244
245/// VS Code chat session index (stored in state.vscdb)
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct ChatSessionIndex {
248    /// Index version
249    #[serde(default = "default_index_version")]
250    pub version: u32,
251
252    /// Session entries keyed by session ID
253    #[serde(default)]
254    pub entries: HashMap<String, ChatSessionIndexEntry>,
255}
256
257fn default_index_version() -> u32 {
258    1
259}
260
261impl Default for ChatSessionIndex {
262    fn default() -> Self {
263        Self {
264            version: 1,
265            entries: HashMap::new(),
266        }
267    }
268}
269
270/// Entry in the chat session index
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct ChatSessionIndexEntry {
274    /// Session ID
275    pub session_id: String,
276
277    /// Session title
278    pub title: String,
279
280    /// Last message timestamp (milliseconds)
281    pub last_message_date: i64,
282
283    /// Whether this session was imported
284    #[serde(default)]
285    pub is_imported: bool,
286
287    /// Initial location (panel, terminal, etc.)
288    #[serde(default = "default_location")]
289    pub initial_location: String,
290
291    /// Whether the session is empty
292    #[serde(default)]
293    pub is_empty: bool,
294}
295
296/// Session with its file path for internal processing
297#[derive(Debug, Clone)]
298pub struct SessionWithPath {
299    pub path: std::path::PathBuf,
300    pub session: ChatSession,
301}
302
303impl SessionWithPath {
304    /// Get the session ID from the session data or from the filename
305    #[allow(dead_code)]
306    pub fn get_session_id(&self) -> String {
307        self.session.session_id.clone().unwrap_or_else(|| {
308            self.path
309                .file_stem()
310                .map(|s| s.to_string_lossy().to_string())
311                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
312        })
313    }
314}
315
316impl ChatSession {
317    /// Get the session ID (from field or will need to be set from filename)
318    #[allow(dead_code)]
319    pub fn get_session_id(&self) -> String {
320        self.session_id
321            .clone()
322            .unwrap_or_else(|| "unknown".to_string())
323    }
324
325    /// Get the title for this session (from custom_title or first message)
326    pub fn title(&self) -> String {
327        // First try custom_title
328        if let Some(title) = &self.custom_title {
329            if !title.is_empty() {
330                return title.clone();
331            }
332        }
333
334        // Try to extract from first message
335        if let Some(first_req) = self.requests.first() {
336            if let Some(msg) = &first_req.message {
337                if let Some(text) = &msg.text {
338                    // Truncate to first 50 chars
339                    let title: String = text.chars().take(50).collect();
340                    if !title.is_empty() {
341                        if title.len() < text.len() {
342                            return format!("{}...", title);
343                        }
344                        return title;
345                    }
346                }
347            }
348        }
349
350        "Untitled".to_string()
351    }
352
353    /// Check if this session is empty
354    pub fn is_empty(&self) -> bool {
355        self.requests.is_empty()
356    }
357
358    /// Get the request count
359    pub fn request_count(&self) -> usize {
360        self.requests.len()
361    }
362
363    /// Get the timestamp range of requests
364    pub fn timestamp_range(&self) -> Option<(i64, i64)> {
365        if self.requests.is_empty() {
366            return None;
367        }
368
369        let timestamps: Vec<i64> = self.requests.iter().filter_map(|r| r.timestamp).collect();
370
371        if timestamps.is_empty() {
372            return None;
373        }
374
375        let min = *timestamps.iter().min().unwrap();
376        let max = *timestamps.iter().max().unwrap();
377        Some((min, max))
378    }
379}