Skip to main content

limit_cli/
session_share.rs

1use crate::error::CliError;
2use chrono::{DateTime, Utc};
3use limit_llm::Message;
4use std::fs;
5use std::path::PathBuf;
6
7/// Export format for session sharing
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum ExportFormat {
10    /// Markdown format (default)
11    Markdown,
12    /// JSON format
13    Json,
14}
15
16/// Session export data
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub struct SessionExport {
19    pub session_id: String,
20    pub created_at: DateTime<Utc>,
21    pub exported_at: DateTime<Utc>,
22    pub model: Option<String>,
23    pub messages: Vec<ExportedMessage>,
24    pub total_input_tokens: u64,
25    pub total_output_tokens: u64,
26}
27
28/// A single message in the export
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct ExportedMessage {
31    pub role: String,
32    pub content: String,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub timestamp: Option<DateTime<Utc>>,
35}
36
37impl SessionExport {
38    /// Create a new session export
39    pub fn new(
40        session_id: String,
41        messages: &[Message],
42        total_input_tokens: u64,
43        total_output_tokens: u64,
44        model: Option<String>,
45    ) -> Self {
46        let exported_messages: Vec<ExportedMessage> = messages
47            .iter()
48            .filter(|m| {
49                // Only include User and Assistant messages
50                matches!(m.role, limit_llm::Role::User | limit_llm::Role::Assistant)
51            })
52            .filter(|m| {
53                // Filter out empty messages
54                m.content
55                    .as_ref()
56                    .map(|c| !c.trim().is_empty())
57                    .unwrap_or(false)
58            })
59            .filter(|m| {
60                // Filter out system messages that might be in User role
61                // (e.g., "We've reached the iteration limit" or other auto-generated messages)
62                let content = m.content.as_deref().unwrap_or("");
63                !content.starts_with("We've reached the iteration limit")
64            })
65            .map(|m| ExportedMessage {
66                role: format!("{:?}", m.role),
67                content: m.content.clone().unwrap_or_default(),
68                timestamp: None,
69            })
70            .collect();
71
72        Self {
73            session_id,
74            created_at: Utc::now(),
75            exported_at: Utc::now(),
76            model,
77            messages: exported_messages,
78            total_input_tokens,
79            total_output_tokens,
80        }
81    }
82
83    /// Export to markdown format
84    pub fn to_markdown(&self) -> String {
85        let mut md = String::new();
86
87        // Header
88        md.push_str("# Session Export\n\n");
89
90        // Metadata
91        md.push_str(&format!(
92            "**Session ID:** `{}`\n\n",
93            &self.session_id[..self.session_id.len().min(8)]
94        ));
95        md.push_str(&format!(
96            "**Exported:** {}\n\n",
97            self.exported_at.format("%Y-%m-%d %H:%M:%S UTC")
98        ));
99
100        if let Some(ref model) = self.model {
101            md.push_str(&format!("**Model:** {}\n\n", model));
102        }
103
104        md.push_str(&format!("**Messages:** {}\n\n", self.messages.len()));
105        md.push_str(&format!(
106            "**Tokens:** ↑{} ↓{}\n\n",
107            self.total_input_tokens, self.total_output_tokens
108        ));
109
110        md.push_str("---\n\n");
111
112        // Messages
113        for msg in &self.messages {
114            match msg.role.as_str() {
115                "User" => {
116                    md.push_str(&format!("### 👤 User\n\n{}\n\n", msg.content));
117                }
118                "Assistant" => {
119                    md.push_str(&format!("### 🤖 Assistant\n\n{}\n\n", msg.content));
120                }
121                _ => {
122                    md.push_str(&format!("### {}\n\n{}\n\n", msg.role, msg.content));
123                }
124            }
125            md.push_str("---\n\n");
126        }
127
128        md
129    }
130
131    /// Export to JSON format
132    pub fn to_json(&self) -> Result<String, CliError> {
133        serde_json::to_string_pretty(&self)
134            .map_err(|e| CliError::ConfigError(format!("Failed to serialize to JSON: {}", e)))
135    }
136
137    /// Save export to file
138    pub fn save_to_file(&self, path: &PathBuf, format: ExportFormat) -> Result<(), CliError> {
139        let content = match format {
140            ExportFormat::Markdown => self.to_markdown(),
141            ExportFormat::Json => self.to_json()?,
142        };
143
144        fs::write(path, content)
145            .map_err(|e| CliError::ConfigError(format!("Failed to write export file: {}", e)))?;
146
147        Ok(())
148    }
149
150    /// Copy to clipboard (returns the content that was copied)
151    pub fn to_clipboard(&self, format: ExportFormat) -> Result<String, CliError> {
152        let content = match format {
153            ExportFormat::Markdown => self.to_markdown(),
154            ExportFormat::Json => self.to_json()?,
155        };
156
157        Ok(content)
158    }
159}
160
161/// Share session utility functions
162pub struct SessionShare;
163
164impl SessionShare {
165    /// Export current session to a file in ~/.limit/exports/
166    pub fn export_session(
167        session_id: &str,
168        messages: &[Message],
169        total_input_tokens: u64,
170        total_output_tokens: u64,
171        model: Option<String>,
172        format: ExportFormat,
173    ) -> Result<(PathBuf, SessionExport), CliError> {
174        // Create exports directory
175        let home_dir = dirs::home_dir()
176            .ok_or_else(|| CliError::ConfigError("Failed to get home directory".to_string()))?;
177        let exports_dir = home_dir.join(".limit").join("exports");
178        fs::create_dir_all(&exports_dir).map_err(|e| {
179            CliError::ConfigError(format!("Failed to create exports directory: {}", e))
180        })?;
181
182        // Create export
183        let export = SessionExport::new(
184            session_id.to_string(),
185            messages,
186            total_input_tokens,
187            total_output_tokens,
188            model,
189        );
190
191        // Generate filename with timestamp
192        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
193        let extension = match format {
194            ExportFormat::Markdown => "md",
195            ExportFormat::Json => "json",
196        };
197        let short_id = &session_id[..session_id.len().min(8)];
198        let filename = format!("session_{}_{}.{}", short_id, timestamp, extension);
199        let filepath = exports_dir.join(&filename);
200
201        // Save to file
202        export.save_to_file(&filepath, format)?;
203
204        Ok((filepath, export))
205    }
206
207    /// Generate shareable content for clipboard
208    pub fn generate_share_content(
209        session_id: &str,
210        messages: &[Message],
211        total_input_tokens: u64,
212        total_output_tokens: u64,
213        model: Option<String>,
214        format: ExportFormat,
215    ) -> Result<String, CliError> {
216        let export = SessionExport::new(
217            session_id.to_string(),
218            messages,
219            total_input_tokens,
220            total_output_tokens,
221            model,
222        );
223
224        export.to_clipboard(format)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_session_export_markdown() {
234        let messages = vec![
235            Message {
236                role: limit_llm::Role::User,
237                content: Some("Hello".to_string()),
238                tool_calls: None,
239                tool_call_id: None,
240            },
241            Message {
242                role: limit_llm::Role::Assistant,
243                content: Some("Hi there!".to_string()),
244                tool_calls: None,
245                tool_call_id: None,
246            },
247        ];
248
249        let export = SessionExport::new(
250            "test-session-123".to_string(),
251            &messages,
252            100,
253            50,
254            Some("claude-3".to_string()),
255        );
256
257        let md = export.to_markdown();
258        assert!(md.contains("Session Export"));
259        assert!(md.contains("test-ses"));
260        assert!(md.contains("👤 User"));
261        assert!(md.contains("Hello"));
262        assert!(md.contains("🤖 Assistant"));
263        assert!(md.contains("Hi there!"));
264    }
265
266    #[test]
267    fn test_session_export_json() {
268        let messages = vec![Message {
269            role: limit_llm::Role::User,
270            content: Some("Test".to_string()),
271            tool_calls: None,
272            tool_call_id: None,
273        }];
274
275        let export = SessionExport::new("test-id".to_string(), &messages, 10, 5, None);
276
277        let json = export.to_json().unwrap();
278        assert!(json.contains("\"role\": \"User\""));
279        assert!(json.contains("\"content\": \"Test\""));
280    }
281
282    #[test]
283    fn test_export_filters_tool_messages() {
284        let messages = vec![
285            Message {
286                role: limit_llm::Role::User,
287                content: Some("User message".to_string()),
288                tool_calls: None,
289                tool_call_id: None,
290            },
291            Message {
292                role: limit_llm::Role::Tool,
293                content: Some("Tool result".to_string()),
294                tool_calls: None,
295                tool_call_id: None,
296            },
297            Message {
298                role: limit_llm::Role::System,
299                content: Some("System message".to_string()),
300                tool_calls: None,
301                tool_call_id: None,
302            },
303        ];
304
305        let export = SessionExport::new("test-id".to_string(), &messages, 0, 0, None);
306
307        // Only User and Assistant messages should be exported
308        assert_eq!(export.messages.len(), 1);
309        assert_eq!(export.messages[0].role, "User");
310    }
311}