1use std::path::Path;
4use std::sync::Arc;
5use tokio::fs;
6use tokio::io::AsyncWriteExt;
7use tokio::sync::RwLock;
8
9use super::{config::TelemetryConfig, events::TelemetryEvent, TelemetryError};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ExportFormat {
14 Json,
16
17 JsonLines,
19
20 Csv,
22
23 Markdown,
25
26 Html,
28}
29
30impl ExportFormat {
31 pub fn extension(&self) -> &str {
33 match self {
34 ExportFormat::Json => "json",
35 ExportFormat::JsonLines => "jsonl",
36 ExportFormat::Csv => "csv",
37 ExportFormat::Markdown => "md",
38 ExportFormat::Html => "html",
39 }
40 }
41}
42
43impl std::str::FromStr for ExportFormat {
44 type Err = String;
45
46 fn from_str(s: &str) -> Result<Self, Self::Err> {
47 match s.to_lowercase().as_str() {
48 "json" => Ok(ExportFormat::Json),
49 "jsonl" | "jsonlines" | "ndjson" => Ok(ExportFormat::JsonLines),
50 "csv" => Ok(ExportFormat::Csv),
51 "md" | "markdown" => Ok(ExportFormat::Markdown),
52 "html" => Ok(ExportFormat::Html),
53 _ => Err(format!("Unknown export format: {}", s)),
54 }
55 }
56}
57
58pub struct TelemetryExporter {
60 config: Arc<RwLock<TelemetryConfig>>,
61}
62
63impl TelemetryExporter {
64 pub fn new(config: Arc<RwLock<TelemetryConfig>>) -> Self {
66 Self { config }
67 }
68
69 pub async fn export(
71 &self,
72 events: &[TelemetryEvent],
73 format: ExportFormat,
74 output: &Path,
75 ) -> Result<(), TelemetryError> {
76 match format {
77 ExportFormat::Json => self.export_json(events, output).await,
78 ExportFormat::JsonLines => self.export_jsonlines(events, output).await,
79 ExportFormat::Csv => self.export_csv(events, output).await,
80 ExportFormat::Markdown => self.export_markdown(events, output).await,
81 ExportFormat::Html => self.export_html(events, output).await,
82 }
83 }
84
85 async fn export_json(
87 &self,
88 events: &[TelemetryEvent],
89 output: &Path,
90 ) -> Result<(), TelemetryError> {
91 let json = serde_json::to_string_pretty(events)?;
92 fs::write(output, json).await?;
93 Ok(())
94 }
95
96 async fn export_jsonlines(
98 &self,
99 events: &[TelemetryEvent],
100 output: &Path,
101 ) -> Result<(), TelemetryError> {
102 let mut file = fs::File::create(output).await?;
103
104 for event in events {
105 let json = serde_json::to_string(event)?;
106 file.write_all(json.as_bytes()).await?;
107 file.write_all(b"\n").await?;
108 }
109
110 file.flush().await?;
111 Ok(())
112 }
113
114 async fn export_csv(
116 &self,
117 events: &[TelemetryEvent],
118 output: &Path,
119 ) -> Result<(), TelemetryError> {
120 let mut content = String::new();
121 content.push_str("id,event_type,timestamp,user_id,session_id,metadata\n");
122
123 for event in events {
124 let metadata_json = serde_json::to_string(&event.metadata)
125 .unwrap_or_else(|_| "{}".to_string())
126 .replace('"', "\"\"");
127
128 content.push_str(&format!(
129 "\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"\n",
130 event.id,
131 event.event_type,
132 event.timestamp.to_rfc3339(),
133 event.user_id.as_deref().unwrap_or(""),
134 event.session_id,
135 metadata_json
136 ));
137 }
138
139 fs::write(output, content).await?;
140 Ok(())
141 }
142
143 async fn export_markdown(
145 &self,
146 events: &[TelemetryEvent],
147 output: &Path,
148 ) -> Result<(), TelemetryError> {
149 let mut md = String::new();
150
151 md.push_str("# VoiRS Telemetry Report\n\n");
153 md.push_str(&format!(
154 "**Generated**: {}\n\n",
155 chrono::Utc::now().to_rfc3339()
156 ));
157 md.push_str(&format!("**Total Events**: {}\n\n", events.len()));
158
159 md.push_str("## Event Summary\n\n");
161 let mut event_counts = std::collections::HashMap::new();
162 for event in events {
163 *event_counts.entry(event.event_type).or_insert(0) += 1;
164 }
165
166 md.push_str("| Event Type | Count |\n");
167 md.push_str("|------------|-------|\n");
168 for (event_type, count) in event_counts.iter() {
169 md.push_str(&format!("| {} | {} |\n", event_type, count));
170 }
171 md.push('\n');
172
173 md.push_str("## Recent Events\n\n");
175 let recent_events = events.iter().rev().take(20);
176 md.push_str("| Timestamp | Type | Details |\n");
177 md.push_str("|-----------|------|----------|\n");
178
179 for event in recent_events {
180 let metadata_str = format!("{:?}", event.metadata);
181 let short_metadata = if metadata_str.len() > 50 {
182 format!("{}...", &metadata_str[..50])
183 } else {
184 metadata_str
185 };
186
187 md.push_str(&format!(
188 "| {} | {} | {} |\n",
189 event.timestamp.format("%Y-%m-%d %H:%M:%S"),
190 event.event_type,
191 short_metadata
192 ));
193 }
194
195 fs::write(output, md).await?;
196 Ok(())
197 }
198
199 async fn export_html(
201 &self,
202 events: &[TelemetryEvent],
203 output: &Path,
204 ) -> Result<(), TelemetryError> {
205 let mut html = String::new();
206
207 html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
209 html.push_str("<meta charset=\"UTF-8\">\n");
210 html.push_str("<title>VoiRS Telemetry Report</title>\n");
211 html.push_str("<style>\n");
212 html.push_str("body { font-family: Arial, sans-serif; margin: 20px; }\n");
213 html.push_str("table { border-collapse: collapse; width: 100%; margin: 20px 0; }\n");
214 html.push_str("th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }\n");
215 html.push_str("th { background-color: #4CAF50; color: white; }\n");
216 html.push_str("tr:nth-child(even) { background-color: #f2f2f2; }\n");
217 html.push_str(
218 ".summary { background-color: #e7f3e7; padding: 15px; border-radius: 5px; }\n",
219 );
220 html.push_str("</style>\n");
221 html.push_str("</head>\n<body>\n");
222
223 html.push_str("<h1>VoiRS Telemetry Report</h1>\n");
225 html.push_str(&format!(
226 "<div class=\"summary\"><p><strong>Generated:</strong> {}</p>",
227 chrono::Utc::now().to_rfc3339()
228 ));
229 html.push_str(&format!(
230 "<p><strong>Total Events:</strong> {}</p></div>\n",
231 events.len()
232 ));
233
234 html.push_str("<h2>Event Summary</h2>\n");
236 html.push_str("<table>\n<tr><th>Event Type</th><th>Count</th></tr>\n");
237
238 let mut event_counts = std::collections::HashMap::new();
239 for event in events {
240 *event_counts.entry(event.event_type).or_insert(0) += 1;
241 }
242
243 for (event_type, count) in event_counts.iter() {
244 html.push_str(&format!(
245 "<tr><td>{}</td><td>{}</td></tr>\n",
246 event_type, count
247 ));
248 }
249 html.push_str("</table>\n");
250
251 html.push_str("<h2>Recent Events</h2>\n");
253 html.push_str(
254 "<table>\n<tr><th>Timestamp</th><th>Type</th><th>ID</th><th>Session</th></tr>\n",
255 );
256
257 for event in events.iter().rev().take(50) {
258 html.push_str(&format!(
259 "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
260 event.timestamp.format("%Y-%m-%d %H:%M:%S"),
261 event.event_type,
262 &event.id[..8],
263 &event.session_id[..8]
264 ));
265 }
266 html.push_str("</table>\n");
267
268 html.push_str("</body>\n</html>\n");
270
271 fs::write(output, html).await?;
272 Ok(())
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::telemetry::{config::TelemetryConfig, events::EventType};
280 use std::sync::Arc;
281 use tokio::sync::RwLock;
282
283 #[test]
284 fn test_export_format_extension() {
285 assert_eq!(ExportFormat::Json.extension(), "json");
286 assert_eq!(ExportFormat::JsonLines.extension(), "jsonl");
287 assert_eq!(ExportFormat::Csv.extension(), "csv");
288 assert_eq!(ExportFormat::Markdown.extension(), "md");
289 assert_eq!(ExportFormat::Html.extension(), "html");
290 }
291
292 #[test]
293 fn test_export_format_from_str() {
294 assert_eq!("json".parse::<ExportFormat>().unwrap(), ExportFormat::Json);
295 assert_eq!(
296 "jsonl".parse::<ExportFormat>().unwrap(),
297 ExportFormat::JsonLines
298 );
299 assert_eq!("csv".parse::<ExportFormat>().unwrap(), ExportFormat::Csv);
300 assert_eq!(
301 "markdown".parse::<ExportFormat>().unwrap(),
302 ExportFormat::Markdown
303 );
304 assert_eq!("html".parse::<ExportFormat>().unwrap(), ExportFormat::Html);
305 assert!("invalid".parse::<ExportFormat>().is_err());
306 }
307
308 #[tokio::test]
309 async fn test_export_json() {
310 let temp_file = std::env::temp_dir().join("voirs_export_test.json");
311 let config = Arc::new(RwLock::new(TelemetryConfig::default()));
312 let exporter = TelemetryExporter::new(config);
313
314 let events = vec![TelemetryEvent::command_executed("test".to_string(), 100)];
315
316 let result = exporter
317 .export(&events, ExportFormat::Json, &temp_file)
318 .await;
319 assert!(result.is_ok());
320 assert!(temp_file.exists());
321
322 let _ = std::fs::remove_file(temp_file);
324 }
325
326 #[tokio::test]
327 async fn test_export_jsonlines() {
328 let temp_file = std::env::temp_dir().join("voirs_export_test.jsonl");
329 let config = Arc::new(RwLock::new(TelemetryConfig::default()));
330 let exporter = TelemetryExporter::new(config);
331
332 let events = vec![
333 TelemetryEvent::command_executed("test1".to_string(), 100),
334 TelemetryEvent::command_executed("test2".to_string(), 200),
335 ];
336
337 let result = exporter
338 .export(&events, ExportFormat::JsonLines, &temp_file)
339 .await;
340 assert!(result.is_ok());
341 assert!(temp_file.exists());
342
343 let content = std::fs::read_to_string(&temp_file).unwrap();
345 assert_eq!(content.lines().count(), 2);
346
347 let _ = std::fs::remove_file(temp_file);
349 }
350
351 #[tokio::test]
352 async fn test_export_csv() {
353 let temp_file = std::env::temp_dir().join("voirs_export_test.csv");
354 let config = Arc::new(RwLock::new(TelemetryConfig::default()));
355 let exporter = TelemetryExporter::new(config);
356
357 let events = vec![TelemetryEvent::command_executed("test".to_string(), 100)];
358
359 let result = exporter
360 .export(&events, ExportFormat::Csv, &temp_file)
361 .await;
362 assert!(result.is_ok());
363 assert!(temp_file.exists());
364
365 let content = std::fs::read_to_string(&temp_file).unwrap();
367 assert!(content.starts_with("id,event_type,timestamp"));
368
369 let _ = std::fs::remove_file(temp_file);
371 }
372
373 #[tokio::test]
374 async fn test_export_markdown() {
375 let temp_file = std::env::temp_dir().join("voirs_export_test.md");
376 let config = Arc::new(RwLock::new(TelemetryConfig::default()));
377 let exporter = TelemetryExporter::new(config);
378
379 let events = vec![TelemetryEvent::command_executed("test".to_string(), 100)];
380
381 let result = exporter
382 .export(&events, ExportFormat::Markdown, &temp_file)
383 .await;
384 assert!(result.is_ok());
385 assert!(temp_file.exists());
386
387 let content = std::fs::read_to_string(&temp_file).unwrap();
389 assert!(content.contains("# VoiRS Telemetry Report"));
390 assert!(content.contains("## Event Summary"));
391
392 let _ = std::fs::remove_file(temp_file);
394 }
395
396 #[tokio::test]
397 async fn test_export_html() {
398 let temp_file = std::env::temp_dir().join("voirs_export_test.html");
399 let config = Arc::new(RwLock::new(TelemetryConfig::default()));
400 let exporter = TelemetryExporter::new(config);
401
402 let events = vec![TelemetryEvent::command_executed("test".to_string(), 100)];
403
404 let result = exporter
405 .export(&events, ExportFormat::Html, &temp_file)
406 .await;
407 assert!(result.is_ok());
408 assert!(temp_file.exists());
409
410 let content = std::fs::read_to_string(&temp_file).unwrap();
412 assert!(content.contains("<!DOCTYPE html>"));
413 assert!(content.contains("<title>VoiRS Telemetry Report</title>"));
414
415 let _ = std::fs::remove_file(temp_file);
417 }
418}