Skip to main content

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        // Charts section (text-based metric visualization)
216        if self.config.include_charts {
217            y -= 15.0;
218            current_layer.use_text("Performance Overview", 14.0, Mm(20.0), Mm(y), &font_bold);
219
220            // Success rate bar
221            y -= 8.0;
222            let success_rate = if report.metrics.total_requests > 0 {
223                report.metrics.successful_requests as f64 / report.metrics.total_requests as f64
224            } else {
225                0.0
226            };
227            let bar_len = (success_rate * 30.0) as usize;
228            let bar =
229                format!("Success Rate: [{:>30}] {:.1}%", "#".repeat(bar_len), success_rate * 100.0);
230            current_layer.use_text(bar, 9.0, Mm(20.0), Mm(y), &font);
231
232            // Step completion bar
233            y -= 6.0;
234            let step_rate = if report.total_steps > 0 {
235                report.completed_steps as f64 / report.total_steps as f64
236            } else {
237                0.0
238            };
239            let bar_len = (step_rate * 30.0) as usize;
240            let bar =
241                format!("Steps Done:   [{:>30}] {:.1}%", "#".repeat(bar_len), step_rate * 100.0);
242            current_layer.use_text(bar, 9.0, Mm(20.0), Mm(y), &font);
243
244            // Latency breakdown
245            y -= 6.0;
246            current_layer.use_text(
247                format!(
248                    "Latency (ms):  avg={:.1}  p95={:.1}  p99={:.1}",
249                    report.metrics.avg_latency_ms,
250                    report.metrics.p95_latency_ms,
251                    report.metrics.p99_latency_ms,
252                ),
253                9.0,
254                Mm(20.0),
255                Mm(y),
256                &font,
257            );
258        }
259
260        // Failures section
261        if !report.failures.is_empty() {
262            y -= 15.0;
263            current_layer.use_text("Failures", 14.0, Mm(20.0), Mm(y), &font_bold);
264
265            for failure in &report.failures {
266                y -= 7.0;
267                if y < 20.0 {
268                    break; // Page boundary - would need to add new page
269                }
270                current_layer.use_text(
271                    format!("• {}: {}", failure.step_name, failure.error_message),
272                    9.0,
273                    Mm(25.0),
274                    Mm(y),
275                    &font,
276                );
277            }
278        }
279
280        // Recommendations section
281        if self.config.include_recommendations && !report.recommendations.is_empty() {
282            y -= 15.0;
283            current_layer.use_text("Recommendations", 14.0, Mm(20.0), Mm(y), &font_bold);
284
285            for recommendation in &report.recommendations {
286                y -= 7.0;
287                if y < 20.0 {
288                    break;
289                }
290                current_layer.use_text(
291                    format!("• {}", recommendation),
292                    9.0,
293                    Mm(25.0),
294                    Mm(y),
295                    &font,
296                );
297            }
298        }
299
300        // Footer
301        current_layer.use_text(
302            format!("Generated by MockForge on {}", Utc::now().format("%Y-%m-%d %H:%M UTC")),
303            8.0,
304            Mm(20.0),
305            Mm(10.0),
306            &font,
307        );
308
309        // Save PDF
310        doc.save(&mut BufWriter::new(File::create(output_path)?))
311            .map_err(|e| ReportingError::Pdf(e.to_string()))?;
312
313        Ok(())
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use tempfile::tempdir;
321
322    fn create_test_report() -> ExecutionReport {
323        ExecutionReport {
324            orchestration_name: "test-orch".to_string(),
325            start_time: Utc::now(),
326            end_time: Utc::now(),
327            duration_seconds: 120,
328            status: "Completed".to_string(),
329            total_steps: 5,
330            completed_steps: 5,
331            failed_steps: 0,
332            metrics: ReportMetrics {
333                total_requests: 1000,
334                successful_requests: 980,
335                failed_requests: 20,
336                avg_latency_ms: 125.5,
337                p95_latency_ms: 250.0,
338                p99_latency_ms: 350.0,
339                error_rate: 0.02,
340            },
341            failures: vec![],
342            recommendations: vec!["Increase timeout thresholds".to_string()],
343        }
344    }
345
346    #[test]
347    fn test_pdf_generation() {
348        let config = PdfConfig::default();
349        let generator = PdfReportGenerator::new(config);
350        let report = create_test_report();
351
352        let temp_dir = tempdir().unwrap();
353        let output_path = temp_dir.path().join("report.pdf");
354
355        let result = generator.generate(&report, output_path.to_str().unwrap());
356        assert!(result.is_ok());
357        assert!(output_path.exists());
358    }
359
360    #[test]
361    fn test_pdf_config_default() {
362        let config = PdfConfig::default();
363        assert_eq!(config.title, "Chaos Orchestration Report");
364        assert_eq!(config.author, "MockForge");
365        assert!(config.include_charts);
366        assert!(config.include_metrics);
367        assert!(config.include_recommendations);
368    }
369
370    #[test]
371    fn test_pdf_config_custom() {
372        let config = PdfConfig {
373            title: "Custom Report".to_string(),
374            author: "Test Author".to_string(),
375            include_charts: false,
376            include_metrics: true,
377            include_recommendations: false,
378        };
379
380        assert_eq!(config.title, "Custom Report");
381        assert_eq!(config.author, "Test Author");
382        assert!(!config.include_charts);
383        assert!(config.include_metrics);
384        assert!(!config.include_recommendations);
385    }
386
387    #[test]
388    fn test_pdf_config_clone() {
389        let config = PdfConfig::default();
390        let cloned = config.clone();
391        assert_eq!(config.title, cloned.title);
392        assert_eq!(config.author, cloned.author);
393    }
394
395    #[test]
396    fn test_pdf_config_serialize() {
397        let config = PdfConfig::default();
398        let json = serde_json::to_string(&config).unwrap();
399        assert!(json.contains("title"));
400        assert!(json.contains("author"));
401        assert!(json.contains("include_charts"));
402    }
403
404    #[test]
405    fn test_pdf_config_deserialize() {
406        let json = r#"{"title":"Test","author":"Author","include_charts":true,"include_metrics":false,"include_recommendations":true}"#;
407        let config: PdfConfig = serde_json::from_str(json).unwrap();
408        assert_eq!(config.title, "Test");
409        assert_eq!(config.author, "Author");
410        assert!(config.include_charts);
411        assert!(!config.include_metrics);
412    }
413
414    #[test]
415    fn test_execution_report_clone() {
416        let report = create_test_report();
417        let cloned = report.clone();
418        assert_eq!(report.orchestration_name, cloned.orchestration_name);
419        assert_eq!(report.duration_seconds, cloned.duration_seconds);
420    }
421
422    #[test]
423    fn test_execution_report_serialize() {
424        let report = create_test_report();
425        let json = serde_json::to_string(&report).unwrap();
426        assert!(json.contains("orchestration_name"));
427        assert!(json.contains("metrics"));
428        assert!(json.contains("status"));
429    }
430
431    #[test]
432    fn test_report_metrics_clone() {
433        let metrics = ReportMetrics {
434            total_requests: 1000,
435            successful_requests: 980,
436            failed_requests: 20,
437            avg_latency_ms: 100.0,
438            p95_latency_ms: 200.0,
439            p99_latency_ms: 300.0,
440            error_rate: 0.02,
441        };
442
443        let cloned = metrics.clone();
444        assert_eq!(metrics.total_requests, cloned.total_requests);
445        assert_eq!(metrics.error_rate, cloned.error_rate);
446    }
447
448    #[test]
449    fn test_report_metrics_serialize() {
450        let metrics = ReportMetrics {
451            total_requests: 1000,
452            successful_requests: 980,
453            failed_requests: 20,
454            avg_latency_ms: 100.0,
455            p95_latency_ms: 200.0,
456            p99_latency_ms: 300.0,
457            error_rate: 0.02,
458        };
459
460        let json = serde_json::to_string(&metrics).unwrap();
461        assert!(json.contains("total_requests"));
462        assert!(json.contains("error_rate"));
463    }
464
465    #[test]
466    fn test_failure_detail_clone() {
467        let failure = FailureDetail {
468            step_name: "auth-step".to_string(),
469            error_message: "Connection timeout".to_string(),
470            timestamp: Utc::now(),
471        };
472
473        let cloned = failure.clone();
474        assert_eq!(failure.step_name, cloned.step_name);
475        assert_eq!(failure.error_message, cloned.error_message);
476    }
477
478    #[test]
479    fn test_failure_detail_serialize() {
480        let failure = FailureDetail {
481            step_name: "auth-step".to_string(),
482            error_message: "Connection timeout".to_string(),
483            timestamp: Utc::now(),
484        };
485
486        let json = serde_json::to_string(&failure).unwrap();
487        assert!(json.contains("step_name"));
488        assert!(json.contains("error_message"));
489        assert!(json.contains("timestamp"));
490    }
491
492    #[test]
493    fn test_pdf_with_failures() {
494        let config = PdfConfig::default();
495        let generator = PdfReportGenerator::new(config);
496
497        let mut report = create_test_report();
498        report.failures = vec![
499            FailureDetail {
500                step_name: "auth-step".to_string(),
501                error_message: "Connection timeout".to_string(),
502                timestamp: Utc::now(),
503            },
504            FailureDetail {
505                step_name: "data-step".to_string(),
506                error_message: "Invalid response".to_string(),
507                timestamp: Utc::now(),
508            },
509        ];
510        report.failed_steps = 2;
511
512        let temp_dir = tempdir().unwrap();
513        let output_path = temp_dir.path().join("report_with_failures.pdf");
514
515        let result = generator.generate(&report, output_path.to_str().unwrap());
516        assert!(result.is_ok());
517        assert!(output_path.exists());
518    }
519
520    #[test]
521    fn test_pdf_without_metrics() {
522        let config = PdfConfig {
523            include_metrics: false,
524            ..PdfConfig::default()
525        };
526        let generator = PdfReportGenerator::new(config);
527        let report = create_test_report();
528
529        let temp_dir = tempdir().unwrap();
530        let output_path = temp_dir.path().join("report_no_metrics.pdf");
531
532        let result = generator.generate(&report, output_path.to_str().unwrap());
533        assert!(result.is_ok());
534    }
535
536    #[test]
537    fn test_pdf_without_recommendations() {
538        let config = PdfConfig {
539            include_recommendations: false,
540            ..PdfConfig::default()
541        };
542        let generator = PdfReportGenerator::new(config);
543
544        let mut report = create_test_report();
545        report.recommendations = vec![];
546
547        let temp_dir = tempdir().unwrap();
548        let output_path = temp_dir.path().join("report_no_recs.pdf");
549
550        let result = generator.generate(&report, output_path.to_str().unwrap());
551        assert!(result.is_ok());
552    }
553
554    #[test]
555    fn test_pdf_generator_invalid_path() {
556        let config = PdfConfig::default();
557        let generator = PdfReportGenerator::new(config);
558        let report = create_test_report();
559
560        let result = generator.generate(&report, "/nonexistent/path/report.pdf");
561        assert!(result.is_err());
562    }
563
564    #[test]
565    fn test_pdf_config_debug() {
566        let config = PdfConfig::default();
567        let debug = format!("{:?}", config);
568        assert!(debug.contains("PdfConfig"));
569    }
570
571    #[test]
572    fn test_execution_report_debug() {
573        let report = create_test_report();
574        let debug = format!("{:?}", report);
575        assert!(debug.contains("ExecutionReport"));
576    }
577
578    #[test]
579    fn test_report_metrics_debug() {
580        let metrics = ReportMetrics {
581            total_requests: 1000,
582            successful_requests: 980,
583            failed_requests: 20,
584            avg_latency_ms: 100.0,
585            p95_latency_ms: 200.0,
586            p99_latency_ms: 300.0,
587            error_rate: 0.02,
588        };
589        let debug = format!("{:?}", metrics);
590        assert!(debug.contains("ReportMetrics"));
591    }
592}