1use 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#[derive(Debug, Clone, Deserialize)]
27#[serde(rename_all = "camelCase")]
28#[allow(dead_code)] struct 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#[derive(Debug, Clone, Deserialize)]
50#[serde(rename_all = "camelCase")]
51#[allow(dead_code)] struct 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#[derive(Debug, Clone, Deserialize)]
68#[allow(dead_code)] struct RawMessage {
70 text: String,
71 #[serde(default)]
72 parts: Vec<RawMessagePart>,
73}
74
75#[derive(Debug, Clone, Deserialize)]
77#[allow(dead_code)] struct RawMessagePart {
79 #[serde(default)]
80 text: Option<String>,
81 #[serde(default)]
82 kind: Option<String>,
83}
84
85#[derive(Debug, Clone, Deserialize)]
87struct RawVariableData {
88 #[serde(default)]
89 variables: Vec<RawVariable>,
90}
91
92#[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 #[serde(default)]
103 value: Option<serde_json::Value>,
104}
105
106#[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#[derive(Debug, Clone, Deserialize)]
117#[serde(rename_all = "camelCase")]
118#[allow(dead_code)] struct 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#[derive(Debug, Clone, Deserialize)]
130#[allow(dead_code)] struct RawMode {
132 #[serde(default)]
133 id: Option<String>,
134 #[serde(default)]
135 kind: Option<String>,
136}
137
138#[derive(Debug, Clone, Deserialize)]
140struct RawSelectedModel {
141 #[serde(default)]
142 identifier: Option<String>,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct ChatSession {
152 pub id: String,
154 pub workspace_id: String,
156 pub created_at: DateTime<Utc>,
158 pub updated_at: DateTime<Utc>,
160 pub messages: Vec<ChatMessage>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub model: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub mode: Option<String>,
168}
169
170impl ChatSession {
171 #[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 #[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 pub fn add_message(&mut self, message: ChatMessage) {
208 self.updated_at = message.timestamp;
209 self.messages.push(message);
210 }
211
212 #[must_use]
214 pub fn message_count(&self) -> usize {
215 self.messages.len()
216 }
217
218 #[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 #[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 #[must_use]
238 pub fn is_empty(&self) -> bool {
239 self.messages.is_empty()
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ChatMessage {
246 pub role: MessageRole,
248 pub content: String,
250 pub timestamp: DateTime<Utc>,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 pub agent: Option<String>,
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub variables: Vec<Variable>,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct Variable {
263 pub kind: String,
265 pub name: String,
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub value: Option<String>,
270}
271
272impl ChatMessage {
273 #[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 #[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 #[must_use]
299 pub fn with_agent(mut self, agent: String) -> Self {
300 self.agent = Some(agent);
301 self
302 }
303
304 #[must_use]
306 pub fn with_variables(mut self, variables: Vec<Variable>) -> Self {
307 self.variables = variables;
308 self
309 }
310
311 #[must_use]
313 pub fn content_len(&self) -> usize {
314 self.content.len()
315 }
316
317 #[must_use]
319 pub fn has_agent(&self) -> bool {
320 self.agent.is_some()
321 }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "lowercase")]
327pub enum MessageRole {
328 User,
330 Assistant,
332 System,
334}
335
336impl MessageRole {
337 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375pub struct WorkspaceInfo {
376 pub storage_id: String,
378 pub folder_path: Option<PathBuf>,
380 pub workspace_file: Option<PathBuf>,
382}
383
384#[derive(Debug, Clone, Deserialize)]
386struct RawWorkspaceJson {
387 #[serde(default)]
389 folder: Option<String>,
390 #[serde(default)]
392 workspace: Option<String>,
393}
394
395impl WorkspaceInfo {
396 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 #[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
439fn parse_file_uri(uri: &str) -> Option<PathBuf> {
441 if let Some(path) = uri.strip_prefix("file://") {
442 let decoded = urlencoding_decode(path);
444 Some(PathBuf::from(decoded))
445 } else {
446 Some(PathBuf::from(uri))
448 }
449}
450
451fn 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#[derive(Debug, Clone)]
476pub struct DiscoveredSession {
477 pub path: PathBuf,
479 pub session_id: String,
481 pub workspace_storage_id: String,
483}
484
485#[derive(Debug)]
487pub struct SessionDiscovery {
488 storage_root: PathBuf,
490}
491
492impl SessionDiscovery {
493 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 #[must_use]
508 pub fn with_root(storage_root: PathBuf) -> Self {
509 Self { storage_root }
510 }
511
512 #[must_use]
514 pub fn storage_root(&self) -> &Path {
515 &self.storage_root
516 }
517
518 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 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 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 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 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 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
655pub 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
669pub 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 for request in raw.requests {
701 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 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
753fn 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 if kind == "promptText" || name.starts_with("prompt:instructions") {
767 return None;
768 }
769
770 let value = match &v.value {
772 Some(serde_json::Value::String(s)) => Some(s.clone()),
773 Some(serde_json::Value::Object(obj)) => {
774 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
792fn 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 if let Some(serde_json::Value::String(s)) = &part.value
801 && !s.is_empty()
802 && s.len() < 500
803 {
804 text_parts.push(s.clone());
806 }
807 }
808 Some("textEditGroup") | Some("codeblockUri") | Some("prepareToolInvocation") => {
809 }
811 _ => {
812 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 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 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 #[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 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 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 fn message_strategy() -> impl Strategy<Value = ChatMessage> {
1213 (
1214 role_strategy(),
1215 ".*", 0i64..2_000_000_000i64, proptest::option::of("@[a-z]+"), proptest::collection::vec(variable_strategy(), 0..3), )
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 fn session_id_strategy() -> impl Strategy<Value = String> {
1234 "[a-z0-9-]{8,36}".prop_map(|s| s.to_string())
1235 }
1236
1237 fn session_strategy() -> impl Strategy<Value = ChatSession> {
1239 (
1240 session_id_strategy(),
1241 session_id_strategy(),
1242 0i64..2_000_000_000i64, proptest::collection::vec(message_strategy(), 0..10), )
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 #[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 #[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 #[test]
1274 fn prop_content_len_matches(msg in message_strategy()) {
1275 prop_assert_eq!(msg.content_len(), msg.content.len());
1276 }
1277
1278 #[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 #[test]
1286 fn prop_message_count_matches(session in session_strategy()) {
1287 prop_assert_eq!(session.message_count(), session.messages.len());
1288 }
1289
1290 #[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 #[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 #[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 #[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 #[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 #[test]
1330 fn prop_display_name_non_empty(role in role_strategy()) {
1331 prop_assert!(!role.display_name().is_empty());
1332 }
1333
1334 #[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 #[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 #[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}