Skip to main content

voirs_cli/telemetry/
export.rs

1//! Telemetry data export functionality
2
3use 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/// Export format for telemetry data
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ExportFormat {
14    /// JSON format
15    Json,
16
17    /// JSON Lines format (one event per line)
18    JsonLines,
19
20    /// CSV format
21    Csv,
22
23    /// Markdown report format
24    Markdown,
25
26    /// HTML report format
27    Html,
28}
29
30impl ExportFormat {
31    /// Get file extension for this format
32    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
58/// Telemetry exporter
59pub struct TelemetryExporter {
60    config: Arc<RwLock<TelemetryConfig>>,
61}
62
63impl TelemetryExporter {
64    /// Create a new telemetry exporter
65    pub fn new(config: Arc<RwLock<TelemetryConfig>>) -> Self {
66        Self { config }
67    }
68
69    /// Export telemetry data
70    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    /// Export as JSON
86    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    /// Export as JSON Lines
97    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    /// Export as CSV
115    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    /// Export as Markdown report
144    async fn export_markdown(
145        &self,
146        events: &[TelemetryEvent],
147        output: &Path,
148    ) -> Result<(), TelemetryError> {
149        let mut md = String::new();
150
151        // Header
152        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        // Event count by type
160        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        // Recent events
174        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    /// Export as HTML report
200    async fn export_html(
201        &self,
202        events: &[TelemetryEvent],
203        output: &Path,
204    ) -> Result<(), TelemetryError> {
205        let mut html = String::new();
206
207        // HTML header
208        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        // Content
224        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        // Event summary table
235        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        // Recent events table
252        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 footer
269        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        // Cleanup
323        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        // Verify content
344        let content = std::fs::read_to_string(&temp_file).unwrap();
345        assert_eq!(content.lines().count(), 2);
346
347        // Cleanup
348        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        // Verify CSV header
366        let content = std::fs::read_to_string(&temp_file).unwrap();
367        assert!(content.starts_with("id,event_type,timestamp"));
368
369        // Cleanup
370        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        // Verify markdown content
388        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        // Cleanup
393        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        // Verify HTML content
411        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        // Cleanup
416        let _ = std::fs::remove_file(temp_file);
417    }
418}