Skip to main content

perfgate_ingest/
lib.rs

1//! Import benchmark results from external frameworks into perfgate's native format.
2//!
3//! Supports:
4//! - **Criterion** (`target/criterion/**/new/estimates.json`)
5//! - **hyperfine** (`--export-json` output)
6//! - **Go benchmark** (`go test -bench . -benchmem` text output)
7//! - **pytest-benchmark** (`.benchmarks/*.json`)
8
9mod criterion;
10mod gobench;
11mod hyperfine;
12mod pytest;
13
14use perfgate_types::{
15    BenchMeta, HostInfo, RUN_SCHEMA_V1, RunMeta, RunReceipt, Sample, Stats, ToolInfo, U64Summary,
16};
17use time::OffsetDateTime;
18use uuid::Uuid;
19
20pub use criterion::parse_criterion;
21pub use gobench::parse_gobench;
22pub use hyperfine::parse_hyperfine;
23pub use pytest::parse_pytest_benchmark;
24
25/// Supported ingest formats.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum IngestFormat {
28    Criterion,
29    Hyperfine,
30    GoBench,
31    PytestBenchmark,
32}
33
34impl IngestFormat {
35    /// Parse a format string (case-insensitive) into an `IngestFormat`.
36    pub fn parse(s: &str) -> Option<Self> {
37        match s.to_lowercase().as_str() {
38            "criterion" => Some(Self::Criterion),
39            "hyperfine" => Some(Self::Hyperfine),
40            "gobench" | "go" => Some(Self::GoBench),
41            "pytest" | "pytest-benchmark" | "pytest_benchmark" => Some(Self::PytestBenchmark),
42            _ => None,
43        }
44    }
45}
46
47/// Request to ingest external benchmark data.
48pub struct IngestRequest {
49    /// The format of the input data.
50    pub format: IngestFormat,
51    /// Raw input content (file contents).
52    pub input: String,
53    /// Benchmark name override. If None, derived from the input data.
54    pub name: Option<String>,
55}
56
57/// Perform an ingest operation, returning a `RunReceipt`.
58pub fn ingest(request: &IngestRequest) -> anyhow::Result<RunReceipt> {
59    match request.format {
60        IngestFormat::Criterion => parse_criterion(&request.input, request.name.as_deref()),
61        IngestFormat::Hyperfine => parse_hyperfine(&request.input, request.name.as_deref()),
62        IngestFormat::GoBench => parse_gobench(&request.input, request.name.as_deref()),
63        IngestFormat::PytestBenchmark => {
64            parse_pytest_benchmark(&request.input, request.name.as_deref())
65        }
66    }
67}
68
69/// Build scaffolding for a `RunReceipt` with sensible defaults.
70fn make_receipt(name: &str, samples: Vec<Sample>, stats: Stats) -> RunReceipt {
71    let now = OffsetDateTime::now_utc();
72    let timestamp = now
73        .format(&time::format_description::well_known::Rfc3339)
74        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
75
76    RunReceipt {
77        schema: RUN_SCHEMA_V1.to_string(),
78        tool: ToolInfo {
79            name: "perfgate-ingest".to_string(),
80            version: env!("CARGO_PKG_VERSION").to_string(),
81        },
82        run: RunMeta {
83            id: Uuid::new_v4().to_string(),
84            started_at: timestamp.clone(),
85            ended_at: timestamp,
86            host: HostInfo {
87                os: std::env::consts::OS.to_string(),
88                arch: std::env::consts::ARCH.to_string(),
89                cpu_count: None,
90                memory_bytes: None,
91                hostname_hash: None,
92            },
93        },
94        bench: BenchMeta {
95            name: name.to_string(),
96            cwd: None,
97            command: vec!["(ingested)".to_string()],
98            repeat: samples.len() as u32,
99            warmup: 0,
100            work_units: None,
101            timeout_ms: None,
102        },
103        samples,
104        stats,
105    }
106}
107
108/// Compute a `U64Summary` from a slice of u64 values.
109fn compute_u64_summary(values: &[u64]) -> U64Summary {
110    if values.is_empty() {
111        return U64Summary {
112            median: 0,
113            min: 0,
114            max: 0,
115            mean: None,
116            stddev: None,
117        };
118    }
119
120    let mut sorted = values.to_vec();
121    sorted.sort_unstable();
122
123    let min = sorted[0];
124    let max = sorted[sorted.len() - 1];
125    let median = if sorted.len().is_multiple_of(2) {
126        (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2
127    } else {
128        sorted[sorted.len() / 2]
129    };
130
131    let sum: f64 = values.iter().map(|&v| v as f64).sum();
132    let mean = sum / values.len() as f64;
133
134    let variance = values
135        .iter()
136        .map(|&v| (v as f64 - mean).powi(2))
137        .sum::<f64>()
138        / values.len() as f64;
139    let stddev = variance.sqrt();
140
141    U64Summary {
142        median,
143        min,
144        max,
145        mean: Some(mean),
146        stddev: Some(stddev),
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_ingest_format_parse() {
156        assert_eq!(
157            IngestFormat::parse("criterion"),
158            Some(IngestFormat::Criterion)
159        );
160        assert_eq!(
161            IngestFormat::parse("Criterion"),
162            Some(IngestFormat::Criterion)
163        );
164        assert_eq!(
165            IngestFormat::parse("hyperfine"),
166            Some(IngestFormat::Hyperfine)
167        );
168        assert_eq!(IngestFormat::parse("gobench"), Some(IngestFormat::GoBench));
169        assert_eq!(IngestFormat::parse("go"), Some(IngestFormat::GoBench));
170        assert_eq!(
171            IngestFormat::parse("pytest"),
172            Some(IngestFormat::PytestBenchmark)
173        );
174        assert_eq!(
175            IngestFormat::parse("pytest-benchmark"),
176            Some(IngestFormat::PytestBenchmark)
177        );
178        assert_eq!(
179            IngestFormat::parse("pytest_benchmark"),
180            Some(IngestFormat::PytestBenchmark)
181        );
182        assert_eq!(IngestFormat::parse("unknown"), None);
183    }
184
185    #[test]
186    fn test_compute_u64_summary_basic() {
187        let values = vec![100, 200, 300, 400, 500];
188        let summary = compute_u64_summary(&values);
189        assert_eq!(summary.median, 300);
190        assert_eq!(summary.min, 100);
191        assert_eq!(summary.max, 500);
192        assert!(summary.mean.is_some());
193        assert!((summary.mean.unwrap() - 300.0).abs() < 0.001);
194    }
195
196    #[test]
197    fn test_compute_u64_summary_even_count() {
198        let values = vec![100, 200, 300, 400];
199        let summary = compute_u64_summary(&values);
200        assert_eq!(summary.median, 250);
201        assert_eq!(summary.min, 100);
202        assert_eq!(summary.max, 400);
203    }
204
205    #[test]
206    fn test_compute_u64_summary_empty() {
207        let summary = compute_u64_summary(&[]);
208        assert_eq!(summary.median, 0);
209        assert_eq!(summary.min, 0);
210        assert_eq!(summary.max, 0);
211        assert!(summary.mean.is_none());
212    }
213
214    #[test]
215    fn test_compute_u64_summary_single() {
216        let values = vec![42];
217        let summary = compute_u64_summary(&values);
218        assert_eq!(summary.median, 42);
219        assert_eq!(summary.min, 42);
220        assert_eq!(summary.max, 42);
221        assert!((summary.mean.unwrap() - 42.0).abs() < 0.001);
222        assert!((summary.stddev.unwrap()).abs() < 0.001);
223    }
224
225    #[test]
226    fn test_make_receipt_structure() {
227        let samples = vec![Sample {
228            wall_ms: 100,
229            exit_code: 0,
230            warmup: false,
231            timed_out: false,
232            cpu_ms: None,
233            page_faults: None,
234            ctx_switches: None,
235            max_rss_kb: None,
236            io_read_bytes: None,
237            io_write_bytes: None,
238            network_packets: None,
239            energy_uj: None,
240            binary_bytes: None,
241            stdout: None,
242            stderr: None,
243        }];
244        let stats = Stats {
245            wall_ms: U64Summary::new(100, 100, 100),
246            cpu_ms: None,
247            page_faults: None,
248            ctx_switches: None,
249            max_rss_kb: None,
250            io_read_bytes: None,
251            io_write_bytes: None,
252            network_packets: None,
253            energy_uj: None,
254            binary_bytes: None,
255            throughput_per_s: None,
256        };
257        let receipt = make_receipt("test-bench", samples, stats);
258        assert_eq!(receipt.schema, RUN_SCHEMA_V1);
259        assert_eq!(receipt.bench.name, "test-bench");
260        assert_eq!(receipt.bench.repeat, 1);
261        assert_eq!(receipt.tool.name, "perfgate-ingest");
262    }
263}