mockforge_reporting/
pdf.rs

1//! PDF report generation for orchestration execution results
2
3use crate::{ReportingError, Result};
4use chrono::{DateTime, Utc};
5use printpdf::*;
6use serde::{Deserialize, Serialize};
7use std::fs::File;
8use std::io::BufWriter;
9
10/// PDF generation configuration
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PdfConfig {
13    pub title: String,
14    pub author: String,
15    pub include_charts: bool,
16    pub include_metrics: bool,
17    pub include_recommendations: bool,
18}
19
20impl Default for PdfConfig {
21    fn default() -> Self {
22        Self {
23            title: "Chaos Orchestration Report".to_string(),
24            author: "MockForge".to_string(),
25            include_charts: true,
26            include_metrics: true,
27            include_recommendations: true,
28        }
29    }
30}
31
32/// Execution report data
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ExecutionReport {
35    pub orchestration_name: String,
36    pub start_time: DateTime<Utc>,
37    pub end_time: DateTime<Utc>,
38    pub duration_seconds: u64,
39    pub status: String,
40    pub total_steps: usize,
41    pub completed_steps: usize,
42    pub failed_steps: usize,
43    pub metrics: ReportMetrics,
44    pub failures: Vec<FailureDetail>,
45    pub recommendations: Vec<String>,
46}
47
48/// Report metrics
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ReportMetrics {
51    pub total_requests: u64,
52    pub successful_requests: u64,
53    pub failed_requests: u64,
54    pub avg_latency_ms: f64,
55    pub p95_latency_ms: f64,
56    pub p99_latency_ms: f64,
57    pub error_rate: f64,
58}
59
60/// Failure detail
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct FailureDetail {
63    pub step_name: String,
64    pub error_message: String,
65    pub timestamp: DateTime<Utc>,
66}
67
68/// PDF report generator
69pub struct PdfReportGenerator {
70    config: PdfConfig,
71}
72
73impl PdfReportGenerator {
74    /// Create a new PDF generator
75    pub fn new(config: PdfConfig) -> Self {
76        Self { config }
77    }
78
79    /// Generate PDF report from execution data
80    pub fn generate(&self, report: &ExecutionReport, output_path: &str) -> Result<()> {
81        let (doc, page1, layer1) =
82            PdfDocument::new(&self.config.title, Mm(210.0), Mm(297.0), "Layer 1");
83
84        let font = doc
85            .add_builtin_font(BuiltinFont::Helvetica)
86            .map_err(|e| ReportingError::Pdf(e.to_string()))?;
87        let font_bold = doc
88            .add_builtin_font(BuiltinFont::HelveticaBold)
89            .map_err(|e| ReportingError::Pdf(e.to_string()))?;
90
91        let current_layer = doc.get_page(page1).get_layer(layer1);
92
93        // Title
94        current_layer.use_text(&self.config.title, 24.0, Mm(20.0), Mm(270.0), &font_bold);
95
96        // Metadata
97        let mut y = 255.0;
98        current_layer.use_text(
99            format!("Orchestration: {}", report.orchestration_name),
100            12.0,
101            Mm(20.0),
102            Mm(y),
103            &font,
104        );
105
106        y -= 7.0;
107        current_layer.use_text(
108            format!("Start: {}", report.start_time.format("%Y-%m-%d %H:%M:%S UTC")),
109            10.0,
110            Mm(20.0),
111            Mm(y),
112            &font,
113        );
114
115        y -= 5.0;
116        current_layer.use_text(
117            format!("End: {}", report.end_time.format("%Y-%m-%d %H:%M:%S UTC")),
118            10.0,
119            Mm(20.0),
120            Mm(y),
121            &font,
122        );
123
124        y -= 5.0;
125        current_layer.use_text(
126            format!("Duration: {}s", report.duration_seconds),
127            10.0,
128            Mm(20.0),
129            Mm(y),
130            &font,
131        );
132
133        y -= 5.0;
134        current_layer.use_text(
135            format!("Status: {}", report.status),
136            10.0,
137            Mm(20.0),
138            Mm(y),
139            &font_bold,
140        );
141
142        // Summary section
143        y -= 15.0;
144        current_layer.use_text("Summary", 14.0, Mm(20.0), Mm(y), &font_bold);
145
146        y -= 7.0;
147        current_layer.use_text(
148            format!("Total Steps: {}", report.total_steps),
149            10.0,
150            Mm(20.0),
151            Mm(y),
152            &font,
153        );
154
155        y -= 5.0;
156        current_layer.use_text(
157            format!("Completed: {}", report.completed_steps),
158            10.0,
159            Mm(20.0),
160            Mm(y),
161            &font,
162        );
163
164        y -= 5.0;
165        current_layer.use_text(
166            format!("Failed: {}", report.failed_steps),
167            10.0,
168            Mm(20.0),
169            Mm(y),
170            &font,
171        );
172
173        // Metrics section
174        if self.config.include_metrics {
175            y -= 15.0;
176            current_layer.use_text("Metrics", 14.0, Mm(20.0), Mm(y), &font_bold);
177
178            y -= 7.0;
179            current_layer.use_text(
180                format!("Total Requests: {}", report.metrics.total_requests),
181                10.0,
182                Mm(20.0),
183                Mm(y),
184                &font,
185            );
186
187            y -= 5.0;
188            current_layer.use_text(
189                format!("Error Rate: {:.2}%", report.metrics.error_rate * 100.0),
190                10.0,
191                Mm(20.0),
192                Mm(y),
193                &font,
194            );
195
196            y -= 5.0;
197            current_layer.use_text(
198                format!("Avg Latency: {:.2}ms", report.metrics.avg_latency_ms),
199                10.0,
200                Mm(20.0),
201                Mm(y),
202                &font,
203            );
204
205            y -= 5.0;
206            current_layer.use_text(
207                format!("P95 Latency: {:.2}ms", report.metrics.p95_latency_ms),
208                10.0,
209                Mm(20.0),
210                Mm(y),
211                &font,
212            );
213        }
214
215        // Failures section
216        if !report.failures.is_empty() {
217            y -= 15.0;
218            current_layer.use_text("Failures", 14.0, Mm(20.0), Mm(y), &font_bold);
219
220            for failure in &report.failures {
221                y -= 7.0;
222                if y < 20.0 {
223                    break; // Page boundary - would need to add new page
224                }
225                current_layer.use_text(
226                    format!("• {}: {}", failure.step_name, failure.error_message),
227                    9.0,
228                    Mm(25.0),
229                    Mm(y),
230                    &font,
231                );
232            }
233        }
234
235        // Recommendations section
236        if self.config.include_recommendations && !report.recommendations.is_empty() {
237            y -= 15.0;
238            current_layer.use_text("Recommendations", 14.0, Mm(20.0), Mm(y), &font_bold);
239
240            for recommendation in &report.recommendations {
241                y -= 7.0;
242                if y < 20.0 {
243                    break;
244                }
245                current_layer.use_text(
246                    format!("• {}", recommendation),
247                    9.0,
248                    Mm(25.0),
249                    Mm(y),
250                    &font,
251                );
252            }
253        }
254
255        // Footer
256        current_layer.use_text(
257            format!("Generated by MockForge on {}", Utc::now().format("%Y-%m-%d %H:%M UTC")),
258            8.0,
259            Mm(20.0),
260            Mm(10.0),
261            &font,
262        );
263
264        // Save PDF
265        doc.save(&mut BufWriter::new(File::create(output_path)?))
266            .map_err(|e| ReportingError::Pdf(e.to_string()))?;
267
268        Ok(())
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use tempfile::tempdir;
276
277    fn create_test_report() -> ExecutionReport {
278        ExecutionReport {
279            orchestration_name: "test-orch".to_string(),
280            start_time: Utc::now(),
281            end_time: Utc::now(),
282            duration_seconds: 120,
283            status: "Completed".to_string(),
284            total_steps: 5,
285            completed_steps: 5,
286            failed_steps: 0,
287            metrics: ReportMetrics {
288                total_requests: 1000,
289                successful_requests: 980,
290                failed_requests: 20,
291                avg_latency_ms: 125.5,
292                p95_latency_ms: 250.0,
293                p99_latency_ms: 350.0,
294                error_rate: 0.02,
295            },
296            failures: vec![],
297            recommendations: vec!["Increase timeout thresholds".to_string()],
298        }
299    }
300
301    #[test]
302    fn test_pdf_generation() {
303        let config = PdfConfig::default();
304        let generator = PdfReportGenerator::new(config);
305        let report = create_test_report();
306
307        let temp_dir = tempdir().unwrap();
308        let output_path = temp_dir.path().join("report.pdf");
309
310        let result = generator.generate(&report, output_path.to_str().unwrap());
311        assert!(result.is_ok());
312        assert!(output_path.exists());
313    }
314
315    #[test]
316    fn test_pdf_config_default() {
317        let config = PdfConfig::default();
318        assert_eq!(config.title, "Chaos Orchestration Report");
319        assert_eq!(config.author, "MockForge");
320        assert!(config.include_charts);
321        assert!(config.include_metrics);
322        assert!(config.include_recommendations);
323    }
324
325    #[test]
326    fn test_pdf_config_custom() {
327        let config = PdfConfig {
328            title: "Custom Report".to_string(),
329            author: "Test Author".to_string(),
330            include_charts: false,
331            include_metrics: true,
332            include_recommendations: false,
333        };
334
335        assert_eq!(config.title, "Custom Report");
336        assert_eq!(config.author, "Test Author");
337        assert!(!config.include_charts);
338        assert!(config.include_metrics);
339        assert!(!config.include_recommendations);
340    }
341
342    #[test]
343    fn test_pdf_config_clone() {
344        let config = PdfConfig::default();
345        let cloned = config.clone();
346        assert_eq!(config.title, cloned.title);
347        assert_eq!(config.author, cloned.author);
348    }
349
350    #[test]
351    fn test_pdf_config_serialize() {
352        let config = PdfConfig::default();
353        let json = serde_json::to_string(&config).unwrap();
354        assert!(json.contains("title"));
355        assert!(json.contains("author"));
356        assert!(json.contains("include_charts"));
357    }
358
359    #[test]
360    fn test_pdf_config_deserialize() {
361        let json = r#"{"title":"Test","author":"Author","include_charts":true,"include_metrics":false,"include_recommendations":true}"#;
362        let config: PdfConfig = serde_json::from_str(json).unwrap();
363        assert_eq!(config.title, "Test");
364        assert_eq!(config.author, "Author");
365        assert!(config.include_charts);
366        assert!(!config.include_metrics);
367    }
368
369    #[test]
370    fn test_execution_report_clone() {
371        let report = create_test_report();
372        let cloned = report.clone();
373        assert_eq!(report.orchestration_name, cloned.orchestration_name);
374        assert_eq!(report.duration_seconds, cloned.duration_seconds);
375    }
376
377    #[test]
378    fn test_execution_report_serialize() {
379        let report = create_test_report();
380        let json = serde_json::to_string(&report).unwrap();
381        assert!(json.contains("orchestration_name"));
382        assert!(json.contains("metrics"));
383        assert!(json.contains("status"));
384    }
385
386    #[test]
387    fn test_report_metrics_clone() {
388        let metrics = ReportMetrics {
389            total_requests: 1000,
390            successful_requests: 980,
391            failed_requests: 20,
392            avg_latency_ms: 100.0,
393            p95_latency_ms: 200.0,
394            p99_latency_ms: 300.0,
395            error_rate: 0.02,
396        };
397
398        let cloned = metrics.clone();
399        assert_eq!(metrics.total_requests, cloned.total_requests);
400        assert_eq!(metrics.error_rate, cloned.error_rate);
401    }
402
403    #[test]
404    fn test_report_metrics_serialize() {
405        let metrics = ReportMetrics {
406            total_requests: 1000,
407            successful_requests: 980,
408            failed_requests: 20,
409            avg_latency_ms: 100.0,
410            p95_latency_ms: 200.0,
411            p99_latency_ms: 300.0,
412            error_rate: 0.02,
413        };
414
415        let json = serde_json::to_string(&metrics).unwrap();
416        assert!(json.contains("total_requests"));
417        assert!(json.contains("error_rate"));
418    }
419
420    #[test]
421    fn test_failure_detail_clone() {
422        let failure = FailureDetail {
423            step_name: "auth-step".to_string(),
424            error_message: "Connection timeout".to_string(),
425            timestamp: Utc::now(),
426        };
427
428        let cloned = failure.clone();
429        assert_eq!(failure.step_name, cloned.step_name);
430        assert_eq!(failure.error_message, cloned.error_message);
431    }
432
433    #[test]
434    fn test_failure_detail_serialize() {
435        let failure = FailureDetail {
436            step_name: "auth-step".to_string(),
437            error_message: "Connection timeout".to_string(),
438            timestamp: Utc::now(),
439        };
440
441        let json = serde_json::to_string(&failure).unwrap();
442        assert!(json.contains("step_name"));
443        assert!(json.contains("error_message"));
444        assert!(json.contains("timestamp"));
445    }
446
447    #[test]
448    fn test_pdf_with_failures() {
449        let config = PdfConfig::default();
450        let generator = PdfReportGenerator::new(config);
451
452        let mut report = create_test_report();
453        report.failures = vec![
454            FailureDetail {
455                step_name: "auth-step".to_string(),
456                error_message: "Connection timeout".to_string(),
457                timestamp: Utc::now(),
458            },
459            FailureDetail {
460                step_name: "data-step".to_string(),
461                error_message: "Invalid response".to_string(),
462                timestamp: Utc::now(),
463            },
464        ];
465        report.failed_steps = 2;
466
467        let temp_dir = tempdir().unwrap();
468        let output_path = temp_dir.path().join("report_with_failures.pdf");
469
470        let result = generator.generate(&report, output_path.to_str().unwrap());
471        assert!(result.is_ok());
472        assert!(output_path.exists());
473    }
474
475    #[test]
476    fn test_pdf_without_metrics() {
477        let config = PdfConfig {
478            include_metrics: false,
479            ..PdfConfig::default()
480        };
481        let generator = PdfReportGenerator::new(config);
482        let report = create_test_report();
483
484        let temp_dir = tempdir().unwrap();
485        let output_path = temp_dir.path().join("report_no_metrics.pdf");
486
487        let result = generator.generate(&report, output_path.to_str().unwrap());
488        assert!(result.is_ok());
489    }
490
491    #[test]
492    fn test_pdf_without_recommendations() {
493        let config = PdfConfig {
494            include_recommendations: false,
495            ..PdfConfig::default()
496        };
497        let generator = PdfReportGenerator::new(config);
498
499        let mut report = create_test_report();
500        report.recommendations = vec![];
501
502        let temp_dir = tempdir().unwrap();
503        let output_path = temp_dir.path().join("report_no_recs.pdf");
504
505        let result = generator.generate(&report, output_path.to_str().unwrap());
506        assert!(result.is_ok());
507    }
508
509    #[test]
510    fn test_pdf_generator_invalid_path() {
511        let config = PdfConfig::default();
512        let generator = PdfReportGenerator::new(config);
513        let report = create_test_report();
514
515        let result = generator.generate(&report, "/nonexistent/path/report.pdf");
516        assert!(result.is_err());
517    }
518
519    #[test]
520    fn test_pdf_config_debug() {
521        let config = PdfConfig::default();
522        let debug = format!("{:?}", config);
523        assert!(debug.contains("PdfConfig"));
524    }
525
526    #[test]
527    fn test_execution_report_debug() {
528        let report = create_test_report();
529        let debug = format!("{:?}", report);
530        assert!(debug.contains("ExecutionReport"));
531    }
532
533    #[test]
534    fn test_report_metrics_debug() {
535        let metrics = ReportMetrics {
536            total_requests: 1000,
537            successful_requests: 980,
538            failed_requests: 20,
539            avg_latency_ms: 100.0,
540            p95_latency_ms: 200.0,
541            p99_latency_ms: 300.0,
542            error_rate: 0.02,
543        };
544        let debug = format!("{:?}", metrics);
545        assert!(debug.contains("ReportMetrics"));
546    }
547}