hindsight_copilot/
session.rs

1// Copyright (c) 2026 - present Nicholas D. Crosbie
2// SPDX-License-Identifier: MIT
3
4//! Chat session types, discovery, and parsing
5//!
6//! This module provides:
7//! - [`ChatSession`] and [`ChatMessage`] types for representing chat data
8//! - [`SessionDiscovery`] for finding VS Code chat session files
9//! - [`parse_session_file`] for parsing session JSON into domain types
10//! - [`WorkspaceInfo`] for correlating workspaces with their storage IDs
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use tracing::{debug, warn};
18
19use crate::error::CopilotError;
20
21// ============================================================================
22// Raw JSON Types (for deserializing VS Code's format)
23// ============================================================================
24
25/// Raw session file structure from VS Code chatSessions/*.json
26#[derive(Debug, Clone, Deserialize)]
27#[serde(rename_all = "camelCase")]
28#[allow(dead_code)] // Fields used for deserialization
29struct RawSession {
30    version: u32,
31    #[serde(default)]
32    requester_username: Option<String>,
33    #[serde(default)]
34    responder_username: Option<String>,
35    session_id: String,
36    #[serde(default)]
37    creation_date: Option<i64>,
38    #[serde(default)]
39    last_message_date: Option<i64>,
40    #[serde(default)]
41    requests: Vec<RawRequest>,
42    #[serde(default)]
43    mode: Option<RawMode>,
44    #[serde(default)]
45    selected_model: Option<RawSelectedModel>,
46}
47
48/// Raw request from the session
49#[derive(Debug, Clone, Deserialize)]
50#[serde(rename_all = "camelCase")]
51#[allow(dead_code)] // Fields used for deserialization
52struct RawRequest {
53    request_id: String,
54    message: Option<RawMessage>,
55    #[serde(default)]
56    variable_data: Option<RawVariableData>,
57    #[serde(default)]
58    response: Vec<RawResponsePart>,
59    #[serde(default)]
60    agent: Option<RawAgent>,
61    timestamp: Option<i64>,
62    #[serde(default)]
63    model_id: Option<String>,
64}
65
66/// Raw message structure
67#[derive(Debug, Clone, Deserialize)]
68#[allow(dead_code)] // Fields used for deserialization
69struct RawMessage {
70    text: String,
71    #[serde(default)]
72    parts: Vec<RawMessagePart>,
73}
74
75/// Raw message part
76#[derive(Debug, Clone, Deserialize)]
77#[allow(dead_code)] // Fields used for deserialization
78struct RawMessagePart {
79    #[serde(default)]
80    text: Option<String>,
81    #[serde(default)]
82    kind: Option<String>,
83}
84
85/// Raw variable data (file references, workspace info, etc.)
86#[derive(Debug, Clone, Deserialize)]
87struct RawVariableData {
88    #[serde(default)]
89    variables: Vec<RawVariable>,
90}
91
92/// Raw variable entry
93#[derive(Debug, Clone, Deserialize)]
94struct RawVariable {
95    #[serde(default)]
96    id: Option<String>,
97    #[serde(default)]
98    name: Option<String>,
99    #[serde(default)]
100    kind: Option<String>,
101    // Value can be a string or an object with URI info
102    #[serde(default)]
103    value: Option<serde_json::Value>,
104}
105
106/// Raw response part
107#[derive(Debug, Clone, Deserialize)]
108struct RawResponsePart {
109    #[serde(default)]
110    kind: Option<String>,
111    #[serde(default)]
112    value: Option<serde_json::Value>,
113}
114
115/// Raw agent info
116#[derive(Debug, Clone, Deserialize)]
117#[serde(rename_all = "camelCase")]
118#[allow(dead_code)] // Fields used for deserialization
119struct RawAgent {
120    #[serde(default)]
121    id: Option<String>,
122    #[serde(default)]
123    name: Option<String>,
124    #[serde(default)]
125    full_name: Option<String>,
126}
127
128/// Raw mode info
129#[derive(Debug, Clone, Deserialize)]
130#[allow(dead_code)] // Fields used for deserialization
131struct RawMode {
132    #[serde(default)]
133    id: Option<String>,
134    #[serde(default)]
135    kind: Option<String>,
136}
137
138/// Raw selected model info
139#[derive(Debug, Clone, Deserialize)]
140struct RawSelectedModel {
141    #[serde(default)]
142    identifier: Option<String>,
143}
144
145// ============================================================================
146// Domain Types
147// ============================================================================
148
149/// Represents a Copilot chat session
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct ChatSession {
152    /// Session ID
153    pub id: String,
154    /// Workspace ID this session belongs to
155    pub workspace_id: String,
156    /// Session creation timestamp
157    pub created_at: DateTime<Utc>,
158    /// Session last updated timestamp
159    pub updated_at: DateTime<Utc>,
160    /// Messages in this session
161    pub messages: Vec<ChatMessage>,
162    /// Model used for this session (e.g., "copilot/claude-opus-4.5")
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub model: Option<String>,
165    /// Session mode (e.g., "agent", "ask")
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub mode: Option<String>,
168}
169
170impl ChatSession {
171    /// Create a new empty session
172    #[must_use]
173    pub fn new(id: String, workspace_id: String, timestamp: DateTime<Utc>) -> Self {
174        Self {
175            id,
176            workspace_id,
177            created_at: timestamp,
178            updated_at: timestamp,
179            messages: Vec::new(),
180            model: None,
181            mode: None,
182        }
183    }
184
185    /// Create a session with model and mode information
186    #[must_use]
187    pub fn with_metadata(
188        id: String,
189        workspace_id: String,
190        created_at: DateTime<Utc>,
191        updated_at: DateTime<Utc>,
192        model: Option<String>,
193        mode: Option<String>,
194    ) -> Self {
195        Self {
196            id,
197            workspace_id,
198            created_at,
199            updated_at,
200            messages: Vec::new(),
201            model,
202            mode,
203        }
204    }
205
206    /// Add a message to the session
207    pub fn add_message(&mut self, message: ChatMessage) {
208        self.updated_at = message.timestamp;
209        self.messages.push(message);
210    }
211
212    /// Get the number of messages in the session
213    #[must_use]
214    pub fn message_count(&self) -> usize {
215        self.messages.len()
216    }
217
218    /// Get all user messages
219    #[must_use]
220    pub fn user_messages(&self) -> Vec<&ChatMessage> {
221        self.messages
222            .iter()
223            .filter(|m| m.role == MessageRole::User)
224            .collect()
225    }
226
227    /// Get all assistant messages
228    #[must_use]
229    pub fn assistant_messages(&self) -> Vec<&ChatMessage> {
230        self.messages
231            .iter()
232            .filter(|m| m.role == MessageRole::Assistant)
233            .collect()
234    }
235
236    /// Check if session is empty
237    #[must_use]
238    pub fn is_empty(&self) -> bool {
239        self.messages.is_empty()
240    }
241}
242
243/// Represents a message in a chat session
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ChatMessage {
246    /// Message role (user, assistant, system)
247    pub role: MessageRole,
248    /// Message content
249    pub content: String,
250    /// Message timestamp
251    pub timestamp: DateTime<Utc>,
252    /// Associated agent (e.g., @workspace)
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub agent: Option<String>,
255    /// Variables/attachments referenced in this message
256    #[serde(default, skip_serializing_if = "Vec::is_empty")]
257    pub variables: Vec<Variable>,
258}
259
260/// A variable/attachment referenced in a chat message
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct Variable {
263    /// Variable kind (e.g., "file", "workspace", "promptFile")
264    pub kind: String,
265    /// Variable name (display name)
266    pub name: String,
267    /// Variable value (file path, content, etc.)
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub value: Option<String>,
270}
271
272impl ChatMessage {
273    /// Create a new user message
274    #[must_use]
275    pub fn user(content: String, timestamp: DateTime<Utc>) -> Self {
276        Self {
277            role: MessageRole::User,
278            content,
279            timestamp,
280            agent: None,
281            variables: Vec::new(),
282        }
283    }
284
285    /// Create a new assistant message
286    #[must_use]
287    pub fn assistant(content: String, timestamp: DateTime<Utc>) -> Self {
288        Self {
289            role: MessageRole::Assistant,
290            content,
291            timestamp,
292            agent: None,
293            variables: Vec::new(),
294        }
295    }
296
297    /// Set the agent for this message
298    #[must_use]
299    pub fn with_agent(mut self, agent: String) -> Self {
300        self.agent = Some(agent);
301        self
302    }
303
304    /// Add variables to this message
305    #[must_use]
306    pub fn with_variables(mut self, variables: Vec<Variable>) -> Self {
307        self.variables = variables;
308        self
309    }
310
311    /// Get the content length
312    #[must_use]
313    pub fn content_len(&self) -> usize {
314        self.content.len()
315    }
316
317    /// Check if message has an associated agent
318    #[must_use]
319    pub fn has_agent(&self) -> bool {
320        self.agent.is_some()
321    }
322}
323
324/// Message role in a chat conversation
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "lowercase")]
327pub enum MessageRole {
328    /// User message
329    User,
330    /// Assistant (Copilot) response
331    Assistant,
332    /// System message
333    System,
334}
335
336impl MessageRole {
337    /// Get the display name for this role
338    #[must_use]
339    pub fn display_name(&self) -> &'static str {
340        match self {
341            Self::User => "User",
342            Self::Assistant => "Copilot",
343            Self::System => "System",
344        }
345    }
346}
347
348/// Get the default Copilot chat sessions directory for the current OS
349#[must_use]
350pub fn default_chat_sessions_dir() -> Option<std::path::PathBuf> {
351    #[cfg(target_os = "macos")]
352    {
353        dirs::home_dir().map(|h| h.join("Library/Application Support/Code/User/workspaceStorage"))
354    }
355    #[cfg(target_os = "windows")]
356    {
357        dirs::config_dir().map(|c| c.join("Code/User/workspaceStorage"))
358    }
359    #[cfg(target_os = "linux")]
360    {
361        dirs::config_dir().map(|c| c.join("Code/User/workspaceStorage"))
362    }
363    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
364    {
365        None
366    }
367}
368
369// ============================================================================
370// Session Discovery
371// ============================================================================
372
373/// Information about a VS Code workspace from workspace.json
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375pub struct WorkspaceInfo {
376    /// The workspace storage ID (directory name hash)
377    pub storage_id: String,
378    /// The workspace folder path (from "folder" field)
379    pub folder_path: Option<PathBuf>,
380    /// The workspace file path (from "workspace" field, for multi-root)
381    pub workspace_file: Option<PathBuf>,
382}
383
384/// Raw workspace.json structure
385#[derive(Debug, Clone, Deserialize)]
386struct RawWorkspaceJson {
387    /// Single folder workspace
388    #[serde(default)]
389    folder: Option<String>,
390    /// Multi-root workspace file
391    #[serde(default)]
392    workspace: Option<String>,
393}
394
395impl WorkspaceInfo {
396    /// Parse workspace.json from a workspace storage directory
397    ///
398    /// # Errors
399    ///
400    /// Returns an error if the file cannot be read or parsed.
401    pub fn from_storage_dir(storage_dir: &Path) -> Result<Self, CopilotError> {
402        let storage_id = storage_dir
403            .file_name()
404            .and_then(|n| n.to_str())
405            .unwrap_or("")
406            .to_string();
407
408        let workspace_json_path = storage_dir.join("workspace.json");
409        if !workspace_json_path.exists() {
410            return Ok(Self {
411                storage_id,
412                folder_path: None,
413                workspace_file: None,
414            });
415        }
416
417        let content = fs::read_to_string(&workspace_json_path)?;
418        let raw: RawWorkspaceJson = serde_json::from_str(&content)?;
419
420        let folder_path = raw.folder.and_then(|f| parse_file_uri(&f));
421        let workspace_file = raw.workspace.and_then(|w| parse_file_uri(&w));
422
423        Ok(Self {
424            storage_id,
425            folder_path,
426            workspace_file,
427        })
428    }
429
430    /// Get the effective workspace path (folder or workspace file)
431    #[must_use]
432    pub fn path(&self) -> Option<&Path> {
433        self.folder_path
434            .as_deref()
435            .or(self.workspace_file.as_deref())
436    }
437}
438
439/// Parse a file:// URI to a PathBuf
440fn parse_file_uri(uri: &str) -> Option<PathBuf> {
441    if let Some(path) = uri.strip_prefix("file://") {
442        // Handle URL-encoded paths
443        let decoded = urlencoding_decode(path);
444        Some(PathBuf::from(decoded))
445    } else {
446        // Not a file URI, treat as raw path
447        Some(PathBuf::from(uri))
448    }
449}
450
451/// Simple URL decoding for file paths (handles %20, etc.)
452fn urlencoding_decode(s: &str) -> String {
453    let mut result = String::with_capacity(s.len());
454    let mut chars = s.chars().peekable();
455
456    while let Some(c) = chars.next() {
457        if c == '%' {
458            let hex: String = chars.by_ref().take(2).collect();
459            if hex.len() == 2
460                && let Ok(byte) = u8::from_str_radix(&hex, 16)
461            {
462                result.push(byte as char);
463                continue;
464            }
465            result.push('%');
466            result.push_str(&hex);
467        } else {
468            result.push(c);
469        }
470    }
471    result
472}
473
474/// Discovered session file with metadata
475#[derive(Debug, Clone)]
476pub struct DiscoveredSession {
477    /// Path to the session JSON file
478    pub path: PathBuf,
479    /// Session ID (from filename)
480    pub session_id: String,
481    /// Workspace storage ID
482    pub workspace_storage_id: String,
483}
484
485/// Session discovery engine for finding VS Code chat sessions
486#[derive(Debug)]
487pub struct SessionDiscovery {
488    /// Root directory for workspace storage
489    storage_root: PathBuf,
490}
491
492impl SessionDiscovery {
493    /// Create a new session discovery using the default storage location
494    ///
495    /// # Errors
496    ///
497    /// Returns an error if the default storage directory cannot be determined.
498    pub fn new() -> Result<Self, CopilotError> {
499        let storage_root =
500            default_chat_sessions_dir().ok_or_else(|| CopilotError::WorkspaceStorageNotFound {
501                path: "default location not available".to_string(),
502            })?;
503        Ok(Self { storage_root })
504    }
505
506    /// Create a session discovery with a custom storage root
507    #[must_use]
508    pub fn with_root(storage_root: PathBuf) -> Self {
509        Self { storage_root }
510    }
511
512    /// Get the storage root path
513    #[must_use]
514    pub fn storage_root(&self) -> &Path {
515        &self.storage_root
516    }
517
518    /// Discover all workspace storage directories
519    ///
520    /// # Errors
521    ///
522    /// Returns an error if the storage root cannot be read.
523    pub fn discover_workspaces(&self) -> Result<Vec<WorkspaceInfo>, CopilotError> {
524        if !self.storage_root.exists() {
525            return Err(CopilotError::WorkspaceStorageNotFound {
526                path: self.storage_root.display().to_string(),
527            });
528        }
529
530        let mut workspaces = Vec::new();
531
532        for entry in fs::read_dir(&self.storage_root)? {
533            let entry = entry?;
534            let path = entry.path();
535
536            if path.is_dir() {
537                // Skip hidden directories
538                if path
539                    .file_name()
540                    .and_then(|n| n.to_str())
541                    .is_some_and(|n| n.starts_with('.'))
542                {
543                    continue;
544                }
545
546                match WorkspaceInfo::from_storage_dir(&path) {
547                    Ok(info) => workspaces.push(info),
548                    Err(e) => {
549                        warn!("Failed to read workspace info from {:?}: {}", path, e);
550                    }
551                }
552            }
553        }
554
555        Ok(workspaces)
556    }
557
558    /// Discover all chat session files
559    ///
560    /// # Errors
561    ///
562    /// Returns an error if the storage directories cannot be read.
563    pub fn discover_sessions(&self) -> Result<Vec<DiscoveredSession>, CopilotError> {
564        if !self.storage_root.exists() {
565            return Err(CopilotError::WorkspaceStorageNotFound {
566                path: self.storage_root.display().to_string(),
567            });
568        }
569
570        let mut sessions = Vec::new();
571
572        for entry in fs::read_dir(&self.storage_root)? {
573            let entry = entry?;
574            let workspace_dir = entry.path();
575
576            if !workspace_dir.is_dir() {
577                continue;
578            }
579
580            let workspace_storage_id = workspace_dir
581                .file_name()
582                .and_then(|n| n.to_str())
583                .unwrap_or("")
584                .to_string();
585
586            // Skip hidden directories
587            if workspace_storage_id.starts_with('.') {
588                continue;
589            }
590
591            let chat_sessions_dir = workspace_dir.join("chatSessions");
592            if !chat_sessions_dir.exists() {
593                continue;
594            }
595
596            match fs::read_dir(&chat_sessions_dir) {
597                Ok(entries) => {
598                    for session_entry in entries.flatten() {
599                        let session_path = session_entry.path();
600                        if session_path.extension().is_some_and(|e| e == "json") {
601                            let session_id = session_path
602                                .file_stem()
603                                .and_then(|n| n.to_str())
604                                .unwrap_or("")
605                                .to_string();
606
607                            sessions.push(DiscoveredSession {
608                                path: session_path,
609                                session_id,
610                                workspace_storage_id: workspace_storage_id.clone(),
611                            });
612                        }
613                    }
614                }
615                Err(e) => {
616                    debug!(
617                        "Failed to read chat sessions from {:?}: {}",
618                        chat_sessions_dir, e
619                    );
620                }
621            }
622        }
623
624        Ok(sessions)
625    }
626
627    /// Discover sessions for a specific workspace folder path
628    ///
629    /// # Errors
630    ///
631    /// Returns an error if discovery fails.
632    pub fn discover_sessions_for_workspace(
633        &self,
634        workspace_path: &Path,
635    ) -> Result<Vec<DiscoveredSession>, CopilotError> {
636        let all_sessions = self.discover_sessions()?;
637        let workspaces = self.discover_workspaces()?;
638
639        // Find workspace storage IDs that match the given path
640        let matching_storage_ids: Vec<_> = workspaces
641            .iter()
642            .filter(|w| w.path().is_some_and(|p| p == workspace_path))
643            .map(|w| &w.storage_id)
644            .collect();
645
646        let filtered: Vec<_> = all_sessions
647            .into_iter()
648            .filter(|s| matching_storage_ids.contains(&&s.workspace_storage_id))
649            .collect();
650
651        Ok(filtered)
652    }
653}
654
655// ============================================================================
656// Session Parsing
657// ============================================================================
658
659/// Parse a chat session from a JSON file
660///
661/// # Errors
662///
663/// Returns an error if the file cannot be read or parsed.
664pub fn parse_session_file(path: &Path, workspace_id: &str) -> Result<ChatSession, CopilotError> {
665    let content = fs::read_to_string(path)?;
666    parse_session_json(&content, workspace_id)
667}
668
669/// Parse a chat session from JSON content
670///
671/// # Errors
672///
673/// Returns an error if the JSON is invalid or doesn't match the expected format.
674pub fn parse_session_json(json: &str, workspace_id: &str) -> Result<ChatSession, CopilotError> {
675    let raw: RawSession = serde_json::from_str(json)?;
676
677    let created_at = raw
678        .creation_date
679        .and_then(DateTime::from_timestamp_millis)
680        .unwrap_or_else(Utc::now);
681
682    let updated_at = raw
683        .last_message_date
684        .and_then(DateTime::from_timestamp_millis)
685        .unwrap_or(created_at);
686
687    let model = raw.selected_model.and_then(|m| m.identifier);
688    let mode = raw.mode.and_then(|m| m.id);
689
690    let mut session = ChatSession::with_metadata(
691        raw.session_id,
692        workspace_id.to_string(),
693        created_at,
694        updated_at,
695        model,
696        mode,
697    );
698
699    // Parse each request/response pair
700    for request in raw.requests {
701        // Extract user message
702        if let Some(msg) = &request.message {
703            let timestamp = request
704                .timestamp
705                .and_then(DateTime::from_timestamp_millis)
706                .unwrap_or(created_at);
707
708            let variables = extract_variables(&request.variable_data);
709
710            let agent_name = request
711                .agent
712                .as_ref()
713                .and_then(|a| a.name.clone().or(a.full_name.clone()));
714
715            let user_msg = ChatMessage::user(msg.text.clone(), timestamp).with_variables(variables);
716
717            let user_msg = if let Some(agent) = agent_name.clone() {
718                user_msg.with_agent(agent)
719            } else {
720                user_msg
721            };
722
723            session.add_message(user_msg);
724        }
725
726        // Extract assistant response
727        let response_text = extract_response_text(&request.response);
728        if !response_text.is_empty() {
729            let timestamp = request
730                .timestamp
731                .and_then(DateTime::from_timestamp_millis)
732                .unwrap_or(created_at);
733
734            let agent_name = request
735                .agent
736                .as_ref()
737                .and_then(|a| a.name.clone().or(a.full_name.clone()));
738
739            let assistant_msg = ChatMessage::assistant(response_text, timestamp);
740            let assistant_msg = if let Some(agent) = agent_name {
741                assistant_msg.with_agent(agent)
742            } else {
743                assistant_msg
744            };
745
746            session.add_message(assistant_msg);
747        }
748    }
749
750    Ok(session)
751}
752
753/// Extract variables from raw variable data
754fn extract_variables(variable_data: &Option<RawVariableData>) -> Vec<Variable> {
755    let Some(data) = variable_data else {
756        return Vec::new();
757    };
758
759    data.variables
760        .iter()
761        .filter_map(|v| {
762            let kind = v.kind.clone().unwrap_or_else(|| "unknown".to_string());
763            let name = v.name.clone().unwrap_or_else(|| "unnamed".to_string());
764
765            // Skip prompt instructions (they're internal)
766            if kind == "promptText" || name.starts_with("prompt:instructions") {
767                return None;
768            }
769
770            // Extract value as string
771            let value = match &v.value {
772                Some(serde_json::Value::String(s)) => Some(s.clone()),
773                Some(serde_json::Value::Object(obj)) => {
774                    // Try to extract path from URI object
775                    obj.get("path")
776                        .and_then(|p| p.as_str())
777                        .map(|s| s.to_string())
778                        .or_else(|| {
779                            obj.get("external")
780                                .and_then(|e| e.as_str())
781                                .map(|s| s.to_string())
782                        })
783                }
784                _ => v.id.clone(),
785            };
786
787            Some(Variable { kind, name, value })
788        })
789        .collect()
790}
791
792/// Extract response text from raw response parts
793fn extract_response_text(response_parts: &[RawResponsePart]) -> String {
794    let mut text_parts = Vec::new();
795
796    for part in response_parts {
797        match part.kind.as_deref() {
798            Some("thinking") => {
799                // Include thinking content if it has meaningful text
800                if let Some(serde_json::Value::String(s)) = &part.value
801                    && !s.is_empty()
802                    && s.len() < 500
803                {
804                    // Skip encrypted/encoded thinking
805                    text_parts.push(s.clone());
806                }
807            }
808            Some("textEditGroup") | Some("codeblockUri") | Some("prepareToolInvocation") => {
809                // Skip these - they're tool-related, not text
810            }
811            _ => {
812                // Default: try to extract text from value
813                if let Some(serde_json::Value::String(s)) = &part.value {
814                    text_parts.push(s.clone());
815                } else if let Some(serde_json::Value::Object(obj)) = &part.value
816                    && let Some(serde_json::Value::String(s)) = obj.get("value")
817                {
818                    text_parts.push(s.clone());
819                }
820            }
821        }
822    }
823
824    text_parts.join("")
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use chrono::TimeZone;
831    use similar_asserts::assert_eq;
832
833    fn sample_timestamp() -> DateTime<Utc> {
834        Utc.with_ymd_and_hms(2026, 1, 17, 2, 33, 6).unwrap()
835    }
836
837    fn sample_session() -> ChatSession {
838        let ts = sample_timestamp();
839        let mut session =
840            ChatSession::new("session-123".to_string(), "workspace-456".to_string(), ts);
841        session.add_message(ChatMessage::user("Hello".to_string(), ts));
842        session.add_message(ChatMessage::assistant("Hi there!".to_string(), ts));
843        session
844    }
845
846    #[test]
847    fn test_session_serialization_roundtrip() {
848        let session = sample_session();
849        let json = serde_json::to_string(&session).expect("serialize");
850        let deserialized: ChatSession = serde_json::from_str(&json).expect("deserialize");
851        assert_eq!(session, deserialized);
852    }
853
854    #[test]
855    fn test_session_new() {
856        let ts = sample_timestamp();
857        let session = ChatSession::new("id".to_string(), "ws".to_string(), ts);
858        assert_eq!(session.id, "id");
859        assert_eq!(session.workspace_id, "ws");
860        assert!(session.is_empty());
861        assert_eq!(session.message_count(), 0);
862    }
863
864    #[test]
865    fn test_session_add_message_updates_timestamp() {
866        let ts1 = sample_timestamp();
867        let ts2 = Utc.with_ymd_and_hms(2026, 1, 17, 3, 0, 0).unwrap();
868
869        let mut session = ChatSession::new("id".to_string(), "ws".to_string(), ts1);
870        assert_eq!(session.updated_at, ts1);
871
872        session.add_message(ChatMessage::user("test".to_string(), ts2));
873        assert_eq!(session.updated_at, ts2);
874    }
875
876    #[test]
877    fn test_session_user_messages() {
878        let session = sample_session();
879        let user_msgs = session.user_messages();
880        assert_eq!(user_msgs.len(), 1);
881        assert_eq!(user_msgs[0].content, "Hello");
882    }
883
884    #[test]
885    fn test_session_assistant_messages() {
886        let session = sample_session();
887        let assistant_msgs = session.assistant_messages();
888        assert_eq!(assistant_msgs.len(), 1);
889        assert_eq!(assistant_msgs[0].content, "Hi there!");
890    }
891
892    #[test]
893    fn test_message_serialization_roundtrip() {
894        let msg = ChatMessage::user("Test message".to_string(), sample_timestamp());
895        let json = serde_json::to_string(&msg).expect("serialize");
896        let deserialized: ChatMessage = serde_json::from_str(&json).expect("deserialize");
897        assert_eq!(msg, deserialized);
898    }
899
900    #[test]
901    fn test_message_with_agent() {
902        let msg = ChatMessage::user("Test".to_string(), sample_timestamp())
903            .with_agent("@workspace".to_string());
904
905        assert!(msg.has_agent());
906        assert_eq!(msg.agent, Some("@workspace".to_string()));
907    }
908
909    #[test]
910    fn test_message_agent_skipped_when_none() {
911        let msg = ChatMessage::user("Test".to_string(), sample_timestamp());
912        let json = serde_json::to_string(&msg).expect("serialize");
913        // agent field should be omitted when None
914        assert!(!json.contains("agent"));
915    }
916
917    #[test]
918    fn test_message_content_len() {
919        let msg = ChatMessage::user("Hello, World!".to_string(), sample_timestamp());
920        assert_eq!(msg.content_len(), 13);
921    }
922
923    #[test]
924    fn test_message_role_serialization() {
925        let roles = vec![
926            (MessageRole::User, "\"user\""),
927            (MessageRole::Assistant, "\"assistant\""),
928            (MessageRole::System, "\"system\""),
929        ];
930
931        for (role, expected) in roles {
932            let json = serde_json::to_string(&role).expect("serialize");
933            assert_eq!(json, expected);
934        }
935    }
936
937    #[test]
938    fn test_message_role_display_name() {
939        assert_eq!(MessageRole::User.display_name(), "User");
940        assert_eq!(MessageRole::Assistant.display_name(), "Copilot");
941        assert_eq!(MessageRole::System.display_name(), "System");
942    }
943
944    #[test]
945    fn test_default_chat_sessions_dir_returns_path() {
946        // This test verifies the function doesn't panic and returns a valid path
947        // on supported platforms
948        let path = default_chat_sessions_dir();
949
950        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
951        {
952            assert!(path.is_some());
953            let p = path.unwrap();
954            assert!(p.to_string_lossy().contains("workspaceStorage"));
955        }
956
957        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
958        {
959            assert!(path.is_none());
960        }
961    }
962
963    // ========================================================================
964    // Session parsing tests
965    // ========================================================================
966
967    #[test]
968    fn test_parse_session_json_empty() {
969        let json = r#"{
970            "version": 3,
971            "sessionId": "test-session-id",
972            "creationDate": 1705500000000,
973            "lastMessageDate": 1705500001000,
974            "requests": []
975        }"#;
976
977        let session = parse_session_json(json, "workspace-123").expect("parse");
978        assert_eq!(session.id, "test-session-id");
979        assert_eq!(session.workspace_id, "workspace-123");
980        assert!(session.is_empty());
981    }
982
983    #[test]
984    fn test_parse_session_json_with_request() {
985        let json = r#"{
986            "version": 3,
987            "sessionId": "session-with-request",
988            "creationDate": 1705500000000,
989            "lastMessageDate": 1705500001000,
990            "requests": [
991                {
992                    "requestId": "request-1",
993                    "message": {
994                        "text": "Hello, Copilot!",
995                        "parts": []
996                    },
997                    "timestamp": 1705500000500,
998                    "response": [
999                        {
1000                            "value": "Hello! How can I help you?",
1001                            "supportThemeIcons": false
1002                        }
1003                    ]
1004                }
1005            ]
1006        }"#;
1007
1008        let session = parse_session_json(json, "ws").expect("parse");
1009        assert_eq!(session.message_count(), 2);
1010
1011        let user_msgs = session.user_messages();
1012        assert_eq!(user_msgs.len(), 1);
1013        assert_eq!(user_msgs[0].content, "Hello, Copilot!");
1014
1015        let assistant_msgs = session.assistant_messages();
1016        assert_eq!(assistant_msgs.len(), 1);
1017        assert_eq!(assistant_msgs[0].content, "Hello! How can I help you?");
1018    }
1019
1020    #[test]
1021    fn test_parse_session_json_with_model() {
1022        let json = r#"{
1023            "version": 3,
1024            "sessionId": "session-with-model",
1025            "creationDate": 1705500000000,
1026            "lastMessageDate": 1705500001000,
1027            "requests": [],
1028            "selectedModel": {
1029                "identifier": "copilot/claude-opus-4.5"
1030            },
1031            "mode": {
1032                "id": "agent",
1033                "kind": "agent"
1034            }
1035        }"#;
1036
1037        let session = parse_session_json(json, "ws").expect("parse");
1038        assert_eq!(session.model, Some("copilot/claude-opus-4.5".to_string()));
1039        assert_eq!(session.mode, Some("agent".to_string()));
1040    }
1041
1042    #[test]
1043    fn test_parse_session_json_with_variables() {
1044        let json = r#"{
1045            "version": 3,
1046            "sessionId": "session-with-vars",
1047            "creationDate": 1705500000000,
1048            "lastMessageDate": 1705500001000,
1049            "requests": [
1050                {
1051                    "requestId": "request-1",
1052                    "message": {
1053                        "text": "Check this file",
1054                        "parts": []
1055                    },
1056                    "variableData": {
1057                        "variables": [
1058                            {
1059                                "kind": "file",
1060                                "name": "main.rs",
1061                                "value": {
1062                                    "path": "/project/src/main.rs",
1063                                    "scheme": "file"
1064                                }
1065                            },
1066                            {
1067                                "kind": "workspace",
1068                                "name": "myproject",
1069                                "value": "Repository info"
1070                            }
1071                        ]
1072                    },
1073                    "timestamp": 1705500000500,
1074                    "response": []
1075                }
1076            ]
1077        }"#;
1078
1079        let session = parse_session_json(json, "ws").expect("parse");
1080        assert_eq!(session.message_count(), 1);
1081
1082        let msg = &session.messages[0];
1083        assert_eq!(msg.variables.len(), 2);
1084        assert_eq!(msg.variables[0].kind, "file");
1085        assert_eq!(msg.variables[0].name, "main.rs");
1086        assert_eq!(
1087            msg.variables[0].value,
1088            Some("/project/src/main.rs".to_string())
1089        );
1090    }
1091
1092    #[test]
1093    fn test_parse_file_uri() {
1094        assert_eq!(
1095            parse_file_uri("file:///Users/test/project"),
1096            Some(PathBuf::from("/Users/test/project"))
1097        );
1098        assert_eq!(
1099            parse_file_uri("file:///path/with%20spaces"),
1100            Some(PathBuf::from("/path/with spaces"))
1101        );
1102        assert_eq!(
1103            parse_file_uri("/raw/path"),
1104            Some(PathBuf::from("/raw/path"))
1105        );
1106    }
1107
1108    #[test]
1109    fn test_urlencoding_decode() {
1110        assert_eq!(urlencoding_decode("hello%20world"), "hello world");
1111        assert_eq!(urlencoding_decode("no%2fslash"), "no/slash");
1112        assert_eq!(urlencoding_decode("plain"), "plain");
1113        assert_eq!(urlencoding_decode("%2F%2F"), "//");
1114    }
1115
1116    #[test]
1117    fn test_variable_serialization() {
1118        let var = Variable {
1119            kind: "file".to_string(),
1120            name: "test.rs".to_string(),
1121            value: Some("/path/to/test.rs".to_string()),
1122        };
1123        let json = serde_json::to_string(&var).expect("serialize");
1124        let deserialized: Variable = serde_json::from_str(&json).expect("deserialize");
1125        assert_eq!(var, deserialized);
1126    }
1127
1128    #[test]
1129    fn test_message_with_variables() {
1130        let vars = vec![Variable {
1131            kind: "file".to_string(),
1132            name: "lib.rs".to_string(),
1133            value: Some("/src/lib.rs".to_string()),
1134        }];
1135
1136        let msg = ChatMessage::user("Test".to_string(), sample_timestamp()).with_variables(vars);
1137
1138        assert_eq!(msg.variables.len(), 1);
1139        assert_eq!(msg.variables[0].name, "lib.rs");
1140    }
1141
1142    #[test]
1143    fn test_session_with_metadata() {
1144        let ts = sample_timestamp();
1145        let session = ChatSession::with_metadata(
1146            "id".to_string(),
1147            "ws".to_string(),
1148            ts,
1149            ts,
1150            Some("gpt-4".to_string()),
1151            Some("ask".to_string()),
1152        );
1153
1154        assert_eq!(session.model, Some("gpt-4".to_string()));
1155        assert_eq!(session.mode, Some("ask".to_string()));
1156    }
1157
1158    #[test]
1159    fn test_workspace_info_path() {
1160        let info = WorkspaceInfo {
1161            storage_id: "abc123".to_string(),
1162            folder_path: Some(PathBuf::from("/project")),
1163            workspace_file: None,
1164        };
1165        assert_eq!(info.path(), Some(Path::new("/project")));
1166
1167        let info2 = WorkspaceInfo {
1168            storage_id: "xyz789".to_string(),
1169            folder_path: None,
1170            workspace_file: Some(PathBuf::from("/multi.code-workspace")),
1171        };
1172        assert_eq!(info2.path(), Some(Path::new("/multi.code-workspace")));
1173
1174        let info3 = WorkspaceInfo {
1175            storage_id: "empty".to_string(),
1176            folder_path: None,
1177            workspace_file: None,
1178        };
1179        assert!(info3.path().is_none());
1180    }
1181}
1182
1183#[cfg(test)]
1184mod property_tests {
1185    use super::*;
1186    use proptest::prelude::*;
1187
1188    /// Strategy to generate valid MessageRole values
1189    fn role_strategy() -> impl Strategy<Value = MessageRole> {
1190        prop_oneof![
1191            Just(MessageRole::User),
1192            Just(MessageRole::Assistant),
1193            Just(MessageRole::System),
1194        ]
1195    }
1196
1197    /// Strategy to generate a Variable
1198    fn variable_strategy() -> impl Strategy<Value = Variable> {
1199        (
1200            prop_oneof![Just("file"), Just("workspace"), Just("selection")],
1201            "[a-z._-]{1,20}",
1202            proptest::option::of("[a-z/._-]{1,50}"),
1203        )
1204            .prop_map(|(kind, name, value)| Variable {
1205                kind: kind.to_string(),
1206                name,
1207                value,
1208            })
1209    }
1210
1211    /// Strategy to generate arbitrary ChatMessage values
1212    fn message_strategy() -> impl Strategy<Value = ChatMessage> {
1213        (
1214            role_strategy(),
1215            ".*",                                                 // content
1216            0i64..2_000_000_000i64,                               // timestamp as unix seconds
1217            proptest::option::of("@[a-z]+"),                      // agent
1218            proptest::collection::vec(variable_strategy(), 0..3), // variables
1219        )
1220            .prop_map(|(role, content, ts, agent, variables)| {
1221                let timestamp = DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now);
1222                ChatMessage {
1223                    role,
1224                    content,
1225                    timestamp,
1226                    agent,
1227                    variables,
1228                }
1229            })
1230    }
1231
1232    /// Strategy to generate session IDs
1233    fn session_id_strategy() -> impl Strategy<Value = String> {
1234        "[a-z0-9-]{8,36}".prop_map(|s| s.to_string())
1235    }
1236
1237    /// Strategy to generate arbitrary ChatSession values
1238    fn session_strategy() -> impl Strategy<Value = ChatSession> {
1239        (
1240            session_id_strategy(),
1241            session_id_strategy(),
1242            0i64..2_000_000_000i64, // created_at timestamp
1243            proptest::collection::vec(message_strategy(), 0..10), // messages
1244        )
1245            .prop_map(|(id, workspace_id, ts, messages)| {
1246                let created_at = DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now);
1247                let mut session = ChatSession::new(id, workspace_id, created_at);
1248                for msg in messages {
1249                    session.add_message(msg);
1250                }
1251                session
1252            })
1253    }
1254
1255    proptest! {
1256        /// Property: Round-trip JSON serialization preserves ChatMessage
1257        #[test]
1258        fn prop_message_roundtrip_serialization(msg in message_strategy()) {
1259            let json = serde_json::to_string(&msg).expect("serialize");
1260            let deserialized: ChatMessage = serde_json::from_str(&json).expect("deserialize");
1261            prop_assert_eq!(msg, deserialized);
1262        }
1263
1264        /// Property: Round-trip JSON serialization preserves ChatSession
1265        #[test]
1266        fn prop_session_roundtrip_serialization(session in session_strategy()) {
1267            let json = serde_json::to_string(&session).expect("serialize");
1268            let deserialized: ChatSession = serde_json::from_str(&json).expect("deserialize");
1269            prop_assert_eq!(session, deserialized);
1270        }
1271
1272        /// Property: content_len equals content.len()
1273        #[test]
1274        fn prop_content_len_matches(msg in message_strategy()) {
1275            prop_assert_eq!(msg.content_len(), msg.content.len());
1276        }
1277
1278        /// Property: has_agent is true iff agent is Some
1279        #[test]
1280        fn prop_has_agent_consistency(msg in message_strategy()) {
1281            prop_assert_eq!(msg.has_agent(), msg.agent.is_some());
1282        }
1283
1284        /// Property: message_count equals messages.len()
1285        #[test]
1286        fn prop_message_count_matches(session in session_strategy()) {
1287            prop_assert_eq!(session.message_count(), session.messages.len());
1288        }
1289
1290        /// Property: is_empty is true iff messages is empty
1291        #[test]
1292        fn prop_is_empty_consistency(session in session_strategy()) {
1293            prop_assert_eq!(session.is_empty(), session.messages.is_empty());
1294        }
1295
1296        /// Property: user_messages returns only User role messages
1297        #[test]
1298        fn prop_user_messages_role(session in session_strategy()) {
1299            for msg in session.user_messages() {
1300                prop_assert_eq!(msg.role, MessageRole::User);
1301            }
1302        }
1303
1304        /// Property: assistant_messages returns only Assistant role messages
1305        #[test]
1306        fn prop_assistant_messages_role(session in session_strategy()) {
1307            for msg in session.assistant_messages() {
1308                prop_assert_eq!(msg.role, MessageRole::Assistant);
1309            }
1310        }
1311
1312        /// Property: user_messages + assistant_messages count <= total messages
1313        #[test]
1314        fn prop_message_filter_counts(session in session_strategy()) {
1315            let user_count = session.user_messages().len();
1316            let assistant_count = session.assistant_messages().len();
1317            prop_assert!(user_count + assistant_count <= session.message_count());
1318        }
1319
1320        /// Property: MessageRole serializes to lowercase
1321        #[test]
1322        fn prop_role_serialization_lowercase(role in role_strategy()) {
1323            let json = serde_json::to_string(&role).expect("serialize");
1324            let value = json.trim_matches('"');
1325            prop_assert_eq!(value, value.to_lowercase());
1326        }
1327
1328        /// Property: display_name returns non-empty string
1329        #[test]
1330        fn prop_display_name_non_empty(role in role_strategy()) {
1331            prop_assert!(!role.display_name().is_empty());
1332        }
1333
1334        /// Property: with_agent sets agent correctly
1335        #[test]
1336        fn prop_with_agent_sets_agent(content in ".*", agent in "@[a-z]+") {
1337            let ts = Utc::now();
1338            let msg = ChatMessage::user(content, ts).with_agent(agent.clone());
1339            prop_assert!(msg.has_agent());
1340            prop_assert_eq!(msg.agent, Some(agent));
1341        }
1342
1343        /// Property: ChatMessage::user creates User role
1344        #[test]
1345        fn prop_user_message_has_user_role(content in ".*") {
1346            let msg = ChatMessage::user(content, Utc::now());
1347            prop_assert_eq!(msg.role, MessageRole::User);
1348            prop_assert!(!msg.has_agent());
1349        }
1350
1351        /// Property: ChatMessage::assistant creates Assistant role
1352        #[test]
1353        fn prop_assistant_message_has_assistant_role(content in ".*") {
1354            let msg = ChatMessage::assistant(content, Utc::now());
1355            prop_assert_eq!(msg.role, MessageRole::Assistant);
1356            prop_assert!(!msg.has_agent());
1357        }
1358    }
1359}