1use crate::error::CliError;
2use chrono::{DateTime, Utc};
3use limit_llm::Message;
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum ExportFormat {
10 Markdown,
12 Json,
14}
15
16#[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#[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 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 matches!(m.role, limit_llm::Role::User | limit_llm::Role::Assistant)
51 })
52 .filter(|m| {
53 m.content
55 .as_ref()
56 .map(|c| !c.trim().is_empty())
57 .unwrap_or(false)
58 })
59 .filter(|m| {
60 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 pub fn to_markdown(&self) -> String {
85 let mut md = String::new();
86
87 md.push_str("# Session Export\n\n");
89
90 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 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 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 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 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
161pub struct SessionShare;
163
164impl SessionShare {
165 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 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 let export = SessionExport::new(
184 session_id.to_string(),
185 messages,
186 total_input_tokens,
187 total_output_tokens,
188 model,
189 );
190
191 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 export.save_to_file(&filepath, format)?;
203
204 Ok((filepath, export))
205 }
206
207 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 assert_eq!(export.messages.len(), 1);
309 assert_eq!(export.messages[0].role, "User");
310 }
311}