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!(
295            "json".parse::<ExportFormat>().expect("valid format"),
296            ExportFormat::Json
297        );
298        assert_eq!(
299            "jsonl".parse::<ExportFormat>().expect("valid format"),
300            ExportFormat::JsonLines
301        );
302        assert_eq!(
303            "csv".parse::<ExportFormat>().expect("valid format"),
304            ExportFormat::Csv
305        );
306        assert_eq!(
307            "markdown".parse::<ExportFormat>().expect("valid format"),
308            ExportFormat::Markdown
309        );
310        assert_eq!(
311            "html".parse::<ExportFormat>().expect("valid format"),
312            ExportFormat::Html
313        );
314        assert!("invalid".parse::<ExportFormat>().is_err());
315    }
316
317    #[tokio::test]
318    async fn test_export_json() {
319        let temp_file = std::env::temp_dir().join("voirs_export_test.json");
320        let config = Arc::new(RwLock::new(TelemetryConfig::default()));
321        let exporter = TelemetryExporter::new(config);
322
323        let events = vec![TelemetryEvent::command_executed("test".to_string(), 100)];
324
325        let result = exporter
326            .export(&events, ExportFormat::Json, &temp_file)
327            .await;
328        assert!(result.is_ok());
329        assert!(temp_file.exists());
330
331        // Cleanup
332        let _ = std::fs::remove_file(temp_file);
333    }
334
335    #[tokio::test]
336    async fn test_export_jsonlines() {
337        let temp_file = std::env::temp_dir().join("voirs_export_test.jsonl");
338        let config = Arc::new(RwLock::new(TelemetryConfig::default()));
339        let exporter = TelemetryExporter::new(config);
340
341        let events = vec![
342            TelemetryEvent::command_executed("test1".to_string(), 100),
343            TelemetryEvent::command_executed("test2".to_string(), 200),
344        ];
345
346        let result = exporter
347            .export(&events, ExportFormat::JsonLines, &temp_file)
348            .await;
349        assert!(result.is_ok());
350        assert!(temp_file.exists());
351
352        // Verify content
353        let content = std::fs::read_to_string(&temp_file).unwrap();
354        assert_eq!(content.lines().count(), 2);
355
356        // Cleanup
357        let _ = std::fs::remove_file(temp_file);
358    }
359
360    #[tokio::test]
361    async fn test_export_csv() {
362        let temp_file = std::env::temp_dir().join("voirs_export_test.csv");
363        let config = Arc::new(RwLock::new(TelemetryConfig::default()));
364        let exporter = TelemetryExporter::new(config);
365
366        let events = vec![TelemetryEvent::command_executed("test".to_string(), 100)];
367
368        let result = exporter
369            .export(&events, ExportFormat::Csv, &temp_file)
370            .await;
371        assert!(result.is_ok());
372        assert!(temp_file.exists());
373
374        // Verify CSV header
375        let content = std::fs::read_to_string(&temp_file).unwrap();
376        assert!(content.starts_with("id,event_type,timestamp"));
377
378        // Cleanup
379        let _ = std::fs::remove_file(temp_file);
380    }
381
382    #[tokio::test]
383    async fn test_export_markdown() {
384        let temp_file = std::env::temp_dir().join("voirs_export_test.md");
385        let config = Arc::new(RwLock::new(TelemetryConfig::default()));
386        let exporter = TelemetryExporter::new(config);
387
388        let events = vec![TelemetryEvent::command_executed("test".to_string(), 100)];
389
390        let result = exporter
391            .export(&events, ExportFormat::Markdown, &temp_file)
392            .await;
393        assert!(result.is_ok());
394        assert!(temp_file.exists());
395
396        // Verify markdown content
397        let content = std::fs::read_to_string(&temp_file).unwrap();
398        assert!(content.contains("# VoiRS Telemetry Report"));
399        assert!(content.contains("## Event Summary"));
400
401        // Cleanup
402        let _ = std::fs::remove_file(temp_file);
403    }
404
405    #[tokio::test]
406    async fn test_export_html() {
407        let temp_file = std::env::temp_dir().join("voirs_export_test.html");
408        let config = Arc::new(RwLock::new(TelemetryConfig::default()));
409        let exporter = TelemetryExporter::new(config);
410
411        let events = vec![TelemetryEvent::command_executed("test".to_string(), 100)];
412
413        let result = exporter
414            .export(&events, ExportFormat::Html, &temp_file)
415            .await;
416        assert!(result.is_ok());
417        assert!(temp_file.exists());
418
419        // Verify HTML content
420        let content = std::fs::read_to_string(&temp_file).unwrap();
421        assert!(content.contains("<!DOCTYPE html>"));
422        assert!(content.contains("<title>VoiRS Telemetry Report</title>"));
423
424        // Cleanup
425        let _ = std::fs::remove_file(temp_file);
426    }
427}