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
88fn default_version() -> u32 {
89    3
90}
91
92fn default_location() -> String {
93    "panel".to_string()
94}
95
96/// A single chat request (message + response)
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct ChatRequest {
100    /// Request timestamp (milliseconds)
101    #[serde(default)]
102    pub timestamp: Option<i64>,
103
104    /// The user's message
105    #[serde(default)]
106    pub message: Option<ChatMessage>,
107
108    /// The AI's response (complex structure - use Value for flexibility)
109    #[serde(default)]
110    pub response: Option<serde_json::Value>,
111
112    /// Variable data (context, files, etc.)
113    #[serde(default)]
114    pub variable_data: Option<serde_json::Value>,
115
116    /// Request ID
117    #[serde(default)]
118    pub request_id: Option<String>,
119
120    /// Response ID
121    #[serde(default)]
122    pub response_id: Option<String>,
123
124    /// Model ID
125    #[serde(default)]
126    pub model_id: Option<String>,
127
128    /// Agent information
129    #[serde(default)]
130    pub agent: Option<serde_json::Value>,
131
132    /// Result metadata
133    #[serde(default)]
134    pub result: Option<serde_json::Value>,
135
136    /// Follow-up suggestions
137    #[serde(default)]
138    pub followups: Option<Vec<serde_json::Value>>,
139
140    /// Whether canceled
141    #[serde(default)]
142    pub is_canceled: Option<bool>,
143
144    /// Content references
145    #[serde(default)]
146    pub content_references: Option<Vec<serde_json::Value>>,
147
148    /// Code citations
149    #[serde(default)]
150    pub code_citations: Option<Vec<serde_json::Value>>,
151
152    /// Response markdown info
153    #[serde(default)]
154    pub response_markdown_info: Option<Vec<serde_json::Value>>,
155
156    /// Source session for merged requests
157    #[serde(rename = "_sourceSession", skip_serializing_if = "Option::is_none")]
158    pub source_session: Option<String>,
159}
160
161/// User message in a chat request
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct ChatMessage {
165    /// Message text
166    #[serde(alias = "content")]
167    pub text: Option<String>,
168
169    /// Message parts (for complex messages)
170    #[serde(default)]
171    pub parts: Option<Vec<serde_json::Value>>,
172}
173
174impl ChatMessage {
175    /// Get the text content of this message
176    pub fn get_text(&self) -> String {
177        self.text.clone().unwrap_or_default()
178    }
179}
180
181/// AI response in a chat request
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184#[allow(dead_code)]
185pub struct ChatResponse {
186    /// Response text
187    #[serde(alias = "content")]
188    pub text: Option<String>,
189
190    /// Response parts
191    #[serde(default)]
192    pub parts: Option<Vec<serde_json::Value>>,
193
194    /// Result metadata
195    #[serde(default)]
196    pub result: Option<serde_json::Value>,
197}
198
199/// VS Code chat session index (stored in state.vscdb)
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct ChatSessionIndex {
202    /// Index version
203    #[serde(default = "default_index_version")]
204    pub version: u32,
205
206    /// Session entries keyed by session ID
207    #[serde(default)]
208    pub entries: HashMap<String, ChatSessionIndexEntry>,
209}
210
211fn default_index_version() -> u32 {
212    1
213}
214
215impl Default for ChatSessionIndex {
216    fn default() -> Self {
217        Self {
218            version: 1,
219            entries: HashMap::new(),
220        }
221    }
222}
223
224/// Entry in the chat session index
225#[derive(Debug, Clone, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct ChatSessionIndexEntry {
228    /// Session ID
229    pub session_id: String,
230
231    /// Session title
232    pub title: String,
233
234    /// Last message timestamp (milliseconds)
235    pub last_message_date: i64,
236
237    /// Whether this session was imported
238    #[serde(default)]
239    pub is_imported: bool,
240
241    /// Initial location (panel, terminal, etc.)
242    #[serde(default = "default_location")]
243    pub initial_location: String,
244
245    /// Whether the session is empty
246    #[serde(default)]
247    pub is_empty: bool,
248}
249
250/// Session with its file path for internal processing
251#[derive(Debug, Clone)]
252pub struct SessionWithPath {
253    pub path: std::path::PathBuf,
254    pub session: ChatSession,
255}
256
257impl SessionWithPath {
258    /// Get the session ID from the session data or from the filename
259    #[allow(dead_code)]
260    pub fn get_session_id(&self) -> String {
261        self.session.session_id.clone().unwrap_or_else(|| {
262            self.path
263                .file_stem()
264                .map(|s| s.to_string_lossy().to_string())
265                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
266        })
267    }
268}
269
270impl ChatSession {
271    /// Get the session ID (from field or will need to be set from filename)
272    #[allow(dead_code)]
273    pub fn get_session_id(&self) -> String {
274        self.session_id
275            .clone()
276            .unwrap_or_else(|| "unknown".to_string())
277    }
278
279    /// Get the title for this session (from custom_title or first message)
280    pub fn title(&self) -> String {
281        // First try custom_title
282        if let Some(title) = &self.custom_title {
283            if !title.is_empty() {
284                return title.clone();
285            }
286        }
287
288        // Try to extract from first message
289        if let Some(first_req) = self.requests.first() {
290            if let Some(msg) = &first_req.message {
291                if let Some(text) = &msg.text {
292                    // Truncate to first 50 chars
293                    let title: String = text.chars().take(50).collect();
294                    if !title.is_empty() {
295                        if title.len() < text.len() {
296                            return format!("{}...", title);
297                        }
298                        return title;
299                    }
300                }
301            }
302        }
303
304        "Untitled".to_string()
305    }
306
307    /// Check if this session is empty
308    pub fn is_empty(&self) -> bool {
309        self.requests.is_empty()
310    }
311
312    /// Get the request count
313    pub fn request_count(&self) -> usize {
314        self.requests.len()
315    }
316
317    /// Get the timestamp range of requests
318    pub fn timestamp_range(&self) -> Option<(i64, i64)> {
319        if self.requests.is_empty() {
320            return None;
321        }
322
323        let timestamps: Vec<i64> = self.requests.iter().filter_map(|r| r.timestamp).collect();
324
325        if timestamps.is_empty() {
326            return None;
327        }
328
329        let min = *timestamps.iter().min().unwrap();
330        let max = *timestamps.iter().max().unwrap();
331        Some((min, max))
332    }
333}