chasm_cli/providers/
session_format.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Session format conversion utilities
4//!
5//! Converts between different chat session formats:
6//! - VS Code Copilot Chat format
7//! - OpenAI API format
8//! - Ollama format
9//! - Generic markdown format
10
11use crate::models::{ChatMessage, ChatRequest, ChatSession};
12use serde::{Deserialize, Serialize};
13
14/// Generic message format for import/export
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct GenericMessage {
17    pub role: String,
18    pub content: String,
19    #[serde(default)]
20    pub timestamp: Option<i64>,
21    #[serde(default)]
22    pub model: Option<String>,
23}
24
25/// Generic session format for import/export
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct GenericSession {
28    pub id: String,
29    pub title: Option<String>,
30    pub messages: Vec<GenericMessage>,
31    #[serde(default)]
32    pub created_at: Option<i64>,
33    #[serde(default)]
34    pub updated_at: Option<i64>,
35    #[serde(default)]
36    pub provider: Option<String>,
37    #[serde(default)]
38    pub model: Option<String>,
39}
40
41impl From<ChatSession> for GenericSession {
42    fn from(session: ChatSession) -> Self {
43        let mut messages = Vec::new();
44
45        for request in session.requests {
46            // Add user message
47            if let Some(msg) = &request.message {
48                if let Some(text) = &msg.text {
49                    messages.push(GenericMessage {
50                        role: "user".to_string(),
51                        content: text.clone(),
52                        timestamp: request.timestamp,
53                        model: request.model_id.clone(),
54                    });
55                }
56            }
57
58            // Add assistant response
59            if let Some(response) = &request.response {
60                if let Some(text) = extract_response_text(response) {
61                    messages.push(GenericMessage {
62                        role: "assistant".to_string(),
63                        content: text,
64                        timestamp: request.timestamp,
65                        model: request.model_id.clone(),
66                    });
67                }
68            }
69        }
70
71        GenericSession {
72            id: session
73                .session_id
74                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
75            title: session.custom_title,
76            messages,
77            created_at: Some(session.creation_date),
78            updated_at: Some(session.last_message_date),
79            provider: session.responder_username,
80            model: None,
81        }
82    }
83}
84
85impl From<GenericSession> for ChatSession {
86    fn from(generic: GenericSession) -> Self {
87        let now = chrono::Utc::now().timestamp_millis();
88
89        let mut requests = Vec::new();
90        let mut user_msg: Option<(String, Option<i64>, Option<String>)> = None;
91
92        for msg in generic.messages {
93            match msg.role.as_str() {
94                "user" => {
95                    user_msg = Some((msg.content, msg.timestamp, msg.model));
96                }
97                "assistant" => {
98                    if let Some((user_text, timestamp, model)) = user_msg.take() {
99                        requests.push(ChatRequest {
100                            timestamp: timestamp.or(Some(now)),
101                            message: Some(ChatMessage {
102                                text: Some(user_text),
103                                parts: None,
104                            }),
105                            response: Some(serde_json::json!({
106                                "value": [{"value": msg.content}]
107                            })),
108                            variable_data: None,
109                            request_id: Some(uuid::Uuid::new_v4().to_string()),
110                            response_id: Some(uuid::Uuid::new_v4().to_string()),
111                            model_id: model.or(msg.model),
112                            agent: None,
113                            result: None,
114                            followups: None,
115                            is_canceled: Some(false),
116                            content_references: None,
117                            code_citations: None,
118                            response_markdown_info: None,
119                            source_session: None,
120                        });
121                    }
122                }
123                _ => {}
124            }
125        }
126
127        ChatSession {
128            version: 3,
129            session_id: Some(generic.id),
130            creation_date: generic.created_at.unwrap_or(now),
131            last_message_date: generic.updated_at.unwrap_or(now),
132            is_imported: true,
133            initial_location: "imported".to_string(),
134            custom_title: generic.title,
135            requester_username: Some("user".to_string()),
136            requester_avatar_icon_uri: None,
137            responder_username: generic.provider,
138            responder_avatar_icon_uri: None,
139            requests,
140        }
141    }
142}
143
144/// Convert a session to markdown format
145pub fn session_to_markdown(session: &ChatSession) -> String {
146    let mut md = String::new();
147
148    // Header
149    md.push_str(&format!("# {}\n\n", session.title()));
150
151    if let Some(id) = &session.session_id {
152        md.push_str(&format!("Session ID: `{}`\n\n", id));
153    }
154
155    md.push_str(&format!(
156        "Created: {}\n",
157        format_timestamp(session.creation_date)
158    ));
159    md.push_str(&format!(
160        "Last Updated: {}\n\n",
161        format_timestamp(session.last_message_date)
162    ));
163
164    md.push_str("---\n\n");
165
166    // Messages
167    for (i, request) in session.requests.iter().enumerate() {
168        // User message
169        if let Some(msg) = &request.message {
170            if let Some(text) = &msg.text {
171                md.push_str(&format!("## User ({})\n\n", i + 1));
172                md.push_str(text);
173                md.push_str("\n\n");
174            }
175        }
176
177        // Assistant response
178        if let Some(response) = &request.response {
179            if let Some(text) = extract_response_text(response) {
180                let model = request.model_id.as_deref().unwrap_or("Assistant");
181                md.push_str(&format!("## {} ({})\n\n", model, i + 1));
182                md.push_str(&text);
183                md.push_str("\n\n");
184            }
185        }
186
187        md.push_str("---\n\n");
188    }
189
190    md
191}
192
193/// Parse a markdown file into a session
194pub fn markdown_to_session(markdown: &str, title: Option<String>) -> ChatSession {
195    let now = chrono::Utc::now().timestamp_millis();
196    let session_id = uuid::Uuid::new_v4().to_string();
197
198    // Simple parsing - look for ## User and ## Assistant sections
199    let mut requests = Vec::new();
200    let mut current_user: Option<String> = None;
201    let mut current_assistant: Option<String> = None;
202    let mut in_user = false;
203    let mut in_assistant = false;
204    let mut content = String::new();
205
206    for line in markdown.lines() {
207        if line.starts_with("## User") {
208            // Save previous pair
209            if let Some(user) = current_user.take() {
210                requests.push(create_request(
211                    user,
212                    current_assistant.take().unwrap_or_default(),
213                    now,
214                    None,
215                ));
216            }
217            in_user = true;
218            in_assistant = false;
219            content.clear();
220        } else if line.starts_with("## ") && !line.starts_with("## User") {
221            // Assistant or model response
222            if in_user {
223                current_user = Some(content.trim().to_string());
224            }
225            in_user = false;
226            in_assistant = true;
227            content.clear();
228        } else if line == "---" {
229            if in_assistant {
230                current_assistant = Some(content.trim().to_string());
231            }
232            // Save pair
233            if let Some(user) = current_user.take() {
234                requests.push(create_request(
235                    user,
236                    current_assistant.take().unwrap_or_default(),
237                    now,
238                    None,
239                ));
240            }
241            in_user = false;
242            in_assistant = false;
243            content.clear();
244        } else {
245            content.push_str(line);
246            content.push('\n');
247        }
248    }
249
250    // Handle final pair
251    if in_user {
252        current_user = Some(content.trim().to_string());
253    } else if in_assistant {
254        current_assistant = Some(content.trim().to_string());
255    }
256    if let Some(user) = current_user.take() {
257        requests.push(create_request(
258            user,
259            current_assistant.take().unwrap_or_default(),
260            now,
261            None,
262        ));
263    }
264
265    ChatSession {
266        version: 3,
267        session_id: Some(session_id),
268        creation_date: now,
269        last_message_date: now,
270        is_imported: true,
271        initial_location: "markdown".to_string(),
272        custom_title: title,
273        requester_username: Some("user".to_string()),
274        requester_avatar_icon_uri: None,
275        responder_username: Some("Imported".to_string()),
276        responder_avatar_icon_uri: None,
277        requests,
278    }
279}
280
281/// Create a ChatRequest from user/assistant text
282fn create_request(
283    user_text: String,
284    assistant_text: String,
285    timestamp: i64,
286    model: Option<String>,
287) -> ChatRequest {
288    ChatRequest {
289        timestamp: Some(timestamp),
290        message: Some(ChatMessage {
291            text: Some(user_text),
292            parts: None,
293        }),
294        response: Some(serde_json::json!({
295            "value": [{"value": assistant_text}]
296        })),
297        variable_data: None,
298        request_id: Some(uuid::Uuid::new_v4().to_string()),
299        response_id: Some(uuid::Uuid::new_v4().to_string()),
300        model_id: model,
301        agent: None,
302        result: None,
303        followups: None,
304        is_canceled: Some(false),
305        content_references: None,
306        code_citations: None,
307        response_markdown_info: None,
308        source_session: None,
309    }
310}
311
312/// Extract text from various response formats
313fn extract_response_text(response: &serde_json::Value) -> Option<String> {
314    // Try direct text field
315    if let Some(text) = response.get("text").and_then(|v| v.as_str()) {
316        return Some(text.to_string());
317    }
318
319    // Try value array format (VS Code Copilot format)
320    if let Some(value) = response.get("value").and_then(|v| v.as_array()) {
321        let parts: Vec<String> = value
322            .iter()
323            .filter_map(|v| v.get("value").and_then(|v| v.as_str()))
324            .map(String::from)
325            .collect();
326        if !parts.is_empty() {
327            return Some(parts.join("\n"));
328        }
329    }
330
331    // Try content field (OpenAI format)
332    if let Some(content) = response.get("content").and_then(|v| v.as_str()) {
333        return Some(content.to_string());
334    }
335
336    None
337}
338
339/// Format a timestamp for display
340fn format_timestamp(timestamp: i64) -> String {
341    use chrono::{TimeZone, Utc};
342
343    if timestamp == 0 {
344        return "Unknown".to_string();
345    }
346
347    let dt = Utc.timestamp_millis_opt(timestamp);
348    match dt {
349        chrono::LocalResult::Single(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
350        _ => "Invalid".to_string(),
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_session_to_markdown() {
360        let session = ChatSession {
361            version: 3,
362            session_id: Some("test-123".to_string()),
363            creation_date: 1700000000000,
364            last_message_date: 1700000000000,
365            is_imported: false,
366            initial_location: "panel".to_string(),
367            custom_title: Some("Test Session".to_string()),
368            requester_username: Some("user".to_string()),
369            requester_avatar_icon_uri: None,
370            responder_username: Some("assistant".to_string()),
371            responder_avatar_icon_uri: None,
372            requests: vec![ChatRequest {
373                timestamp: Some(1700000000000),
374                message: Some(ChatMessage {
375                    text: Some("Hello".to_string()),
376                    parts: None,
377                }),
378                response: Some(serde_json::json!({
379                    "value": [{"value": "Hi there!"}]
380                })),
381                variable_data: None,
382                request_id: None,
383                response_id: None,
384                model_id: Some("gpt-4".to_string()),
385                agent: None,
386                result: None,
387                followups: None,
388                is_canceled: None,
389                content_references: None,
390                code_citations: None,
391                response_markdown_info: None,
392                source_session: None,
393            }],
394        };
395
396        let md = session_to_markdown(&session);
397        assert!(md.contains("# Test Session"));
398        assert!(md.contains("Hello"));
399        assert!(md.contains("Hi there!"));
400    }
401
402    #[test]
403    fn test_generic_session_conversion() {
404        let session = ChatSession {
405            version: 3,
406            session_id: Some("test-123".to_string()),
407            creation_date: 1700000000000,
408            last_message_date: 1700000000000,
409            is_imported: false,
410            initial_location: "panel".to_string(),
411            custom_title: Some("Test".to_string()),
412            requester_username: None,
413            requester_avatar_icon_uri: None,
414            responder_username: Some("Copilot".to_string()),
415            responder_avatar_icon_uri: None,
416            requests: vec![],
417        };
418
419        let generic: GenericSession = session.clone().into();
420        assert_eq!(generic.id, "test-123");
421        assert_eq!(generic.title, Some("Test".to_string()));
422
423        let back: ChatSession = generic.into();
424        assert_eq!(back.session_id, Some("test-123".to_string()));
425    }
426}