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                cache_control: None,
241            },
242            Message {
243                role: limit_llm::Role::Assistant,
244                content: Some("Hi there!".to_string()),
245                tool_calls: None,
246                tool_call_id: None,
247                cache_control: None,
248            },
249        ];
250
251        let export = SessionExport::new(
252            "test-session-123".to_string(),
253            &messages,
254            100,
255            50,
256            Some("claude-3".to_string()),
257        );
258
259        let md = export.to_markdown();
260        assert!(md.contains("Session Export"));
261        assert!(md.contains("test-ses"));
262        assert!(md.contains("👤 User"));
263        assert!(md.contains("Hello"));
264        assert!(md.contains("🤖 Assistant"));
265        assert!(md.contains("Hi there!"));
266    }
267
268    #[test]
269    fn test_session_export_json() {
270        let messages = vec![Message {
271            role: limit_llm::Role::User,
272            content: Some("Test".to_string()),
273            tool_calls: None,
274            tool_call_id: None,
275            cache_control: None,
276        }];
277
278        let export = SessionExport::new("test-id".to_string(), &messages, 10, 5, None);
279
280        let json = export.to_json().unwrap();
281        assert!(json.contains("\"role\": \"User\""));
282        assert!(json.contains("\"content\": \"Test\""));
283    }
284
285    #[test]
286    fn test_export_filters_tool_messages() {
287        let messages = vec![
288            Message {
289                role: limit_llm::Role::User,
290                content: Some("User message".to_string()),
291                tool_calls: None,
292                tool_call_id: None,
293                cache_control: None,
294            },
295            Message {
296                role: limit_llm::Role::Tool,
297                content: Some("Tool result".to_string()),
298                tool_calls: None,
299                tool_call_id: None,
300                cache_control: None,
301            },
302            Message {
303                role: limit_llm::Role::System,
304                content: Some("System message".to_string()),
305                tool_calls: None,
306                tool_call_id: None,
307                cache_control: None,
308            },
309        ];
310
311        let export = SessionExport::new("test-id".to_string(), &messages, 0, 0, None);
312
313        assert_eq!(export.messages.len(), 1);
314        assert_eq!(export.messages[0].role, "User");
315    }
316}