1mod paired;
24
25pub use paired::{
26 PAIRED_SCHEMA_V1, PairedBenchMeta, PairedDiffSummary, PairedRunReceipt, PairedSample,
27 PairedSampleHalf, PairedStats,
28};
29
30pub use perfgate_validation::{
31 BENCH_NAME_MAX_LEN, BENCH_NAME_PATTERN, ValidationError as BenchNameValidationError,
32 validate_bench_name,
33};
34
35pub use perfgate_error::{ConfigValidationError, IoError as PerfgateError};
36
37use schemars::JsonSchema;
38use serde::{Deserialize, Serialize};
39use std::collections::BTreeMap;
40
41pub const RUN_SCHEMA_V1: &str = "perfgate.run.v1";
42pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
43pub const COMPARE_SCHEMA_V1: &str = "perfgate.compare.v1";
44pub const REPORT_SCHEMA_V1: &str = "perfgate.report.v1";
45pub const CONFIG_SCHEMA_V1: &str = "perfgate.config.v1";
46
47pub const CHECK_ID_BUDGET: &str = "perf.budget";
49pub const CHECK_ID_BASELINE: &str = "perf.baseline";
50pub const CHECK_ID_HOST: &str = "perf.host";
51pub const CHECK_ID_TOOL_RUNTIME: &str = "tool.runtime";
52pub const FINDING_CODE_METRIC_WARN: &str = "metric_warn";
53pub const FINDING_CODE_METRIC_FAIL: &str = "metric_fail";
54pub const FINDING_CODE_BASELINE_MISSING: &str = "missing";
55pub const FINDING_CODE_HOST_MISMATCH: &str = "host_mismatch";
56pub const FINDING_CODE_RUNTIME_ERROR: &str = "runtime_error";
57pub const VERDICT_REASON_NO_BASELINE: &str = "no_baseline";
58pub const VERDICT_REASON_HOST_MISMATCH: &str = "host_mismatch";
59pub const VERDICT_REASON_TOOL_ERROR: &str = "tool_error";
60pub const VERDICT_REASON_TRUNCATED: &str = "truncated";
61
62pub const STAGE_CONFIG_PARSE: &str = "config_parse";
64pub const STAGE_BASELINE_RESOLVE: &str = "baseline_resolve";
65pub const STAGE_RUN_COMMAND: &str = "run_command";
66pub const STAGE_WRITE_ARTIFACTS: &str = "write_artifacts";
67
68pub const ERROR_KIND_IO: &str = "io_error";
70pub const ERROR_KIND_PARSE: &str = "parse_error";
71pub const ERROR_KIND_EXEC: &str = "exec_error";
72
73pub const BASELINE_REASON_NO_BASELINE: &str = "no_baseline";
75
76pub const CHECK_ID_TOOL_TRUNCATION: &str = "tool.truncation";
78pub const FINDING_CODE_TRUNCATED: &str = "truncated";
79pub const MAX_FINDINGS_DEFAULT: usize = 100;
80
81pub const SENSOR_REPORT_SCHEMA_V1: &str = "sensor.report.v1";
83
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
90#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
91#[serde(rename_all = "snake_case")]
92pub enum CapabilityStatus {
93 Available,
94 Unavailable,
95 Skipped,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
100#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
101pub struct Capability {
102 pub status: CapabilityStatus,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub reason: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
109#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
110pub struct SensorCapabilities {
111 pub baseline: Capability,
112 #[serde(skip_serializing_if = "Option::is_none", default)]
113 pub engine: Option<Capability>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
118#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
119pub struct SensorRunMeta {
120 pub started_at: String,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub ended_at: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub duration_ms: Option<u64>,
125 pub capabilities: SensorCapabilities,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
130#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
131#[serde(rename_all = "snake_case")]
132pub enum SensorVerdictStatus {
133 Pass,
134 Warn,
135 Fail,
136 Skip,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
141#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
142pub struct SensorVerdictCounts {
143 pub info: u32,
144 pub warn: u32,
145 pub error: u32,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
150#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
151pub struct SensorVerdict {
152 pub status: SensorVerdictStatus,
153 pub counts: SensorVerdictCounts,
154 pub reasons: Vec<String>,
155}
156
157#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
159#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
160#[serde(rename_all = "snake_case")]
161pub enum SensorSeverity {
162 Info,
163 Warn,
164 Error,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct SensorFinding {
170 pub check_id: String,
171 pub code: String,
172 pub severity: SensorSeverity,
173 pub message: String,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub fingerprint: Option<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub data: Option<serde_json::Value>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
182#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
183pub struct SensorArtifact {
184 pub path: String,
185 #[serde(rename = "type")]
186 pub artifact_type: String,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
197pub struct SensorReport {
198 pub schema: String,
199 pub tool: ToolInfo,
200 pub run: SensorRunMeta,
201 pub verdict: SensorVerdict,
202 pub findings: Vec<SensorFinding>,
203 #[serde(skip_serializing_if = "Vec::is_empty", default)]
204 pub artifacts: Vec<SensorArtifact>,
205 pub data: serde_json::Value,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
209#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
210pub struct ToolInfo {
211 pub name: String,
212 pub version: String,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
216#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
217pub struct HostInfo {
218 pub os: String,
220
221 pub arch: String,
223
224 #[serde(skip_serializing_if = "Option::is_none", default)]
226 pub cpu_count: Option<u32>,
227
228 #[serde(skip_serializing_if = "Option::is_none", default)]
230 pub memory_bytes: Option<u64>,
231
232 #[serde(skip_serializing_if = "Option::is_none", default)]
235 pub hostname_hash: Option<String>,
236}
237
238#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
246#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
247#[serde(rename_all = "snake_case")]
248pub enum HostMismatchPolicy {
249 #[default]
251 Warn,
252 Error,
254 Ignore,
256}
257
258impl HostMismatchPolicy {
259 pub fn as_str(self) -> &'static str {
270 match self {
271 HostMismatchPolicy::Warn => "warn",
272 HostMismatchPolicy::Error => "error",
273 HostMismatchPolicy::Ignore => "ignore",
274 }
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct HostMismatchInfo {
281 pub reasons: Vec<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
286#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
287pub struct RunMeta {
288 pub id: String,
289 pub started_at: String,
290 pub ended_at: String,
291 pub host: HostInfo,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
295#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
296pub struct BenchMeta {
297 pub name: String,
298
299 #[serde(skip_serializing_if = "Option::is_none")]
301 pub cwd: Option<String>,
302
303 pub command: Vec<String>,
305
306 pub repeat: u32,
307 pub warmup: u32,
308
309 #[serde(skip_serializing_if = "Option::is_none")]
310 pub work_units: Option<u64>,
311
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub timeout_ms: Option<u64>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
317#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
318pub struct Sample {
319 pub wall_ms: u64,
320 pub exit_code: i32,
321
322 #[serde(default)]
323 pub warmup: bool,
324
325 #[serde(default)]
326 pub timed_out: bool,
327
328 #[serde(skip_serializing_if = "Option::is_none", default)]
330 pub cpu_ms: Option<u64>,
331
332 #[serde(skip_serializing_if = "Option::is_none", default)]
334 pub page_faults: Option<u64>,
335
336 #[serde(skip_serializing_if = "Option::is_none", default)]
338 pub ctx_switches: Option<u64>,
339
340 #[serde(skip_serializing_if = "Option::is_none", default)]
341 pub max_rss_kb: Option<u64>,
342
343 #[serde(skip_serializing_if = "Option::is_none", default)]
345 pub binary_bytes: Option<u64>,
346
347 #[serde(skip_serializing_if = "Option::is_none", default)]
349 pub stdout: Option<String>,
350
351 #[serde(skip_serializing_if = "Option::is_none", default)]
353 pub stderr: Option<String>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
357#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
358pub struct U64Summary {
359 pub median: u64,
360 pub min: u64,
361 pub max: u64,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
365#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
366pub struct F64Summary {
367 pub median: f64,
368 pub min: f64,
369 pub max: f64,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
392#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
393pub struct Stats {
394 pub wall_ms: U64Summary,
395
396 #[serde(skip_serializing_if = "Option::is_none", default)]
398 pub cpu_ms: Option<U64Summary>,
399
400 #[serde(skip_serializing_if = "Option::is_none", default)]
402 pub page_faults: Option<U64Summary>,
403
404 #[serde(skip_serializing_if = "Option::is_none", default)]
406 pub ctx_switches: Option<U64Summary>,
407
408 #[serde(skip_serializing_if = "Option::is_none", default)]
409 pub max_rss_kb: Option<U64Summary>,
410
411 #[serde(skip_serializing_if = "Option::is_none", default)]
413 pub binary_bytes: Option<U64Summary>,
414
415 #[serde(skip_serializing_if = "Option::is_none", default)]
416 pub throughput_per_s: Option<F64Summary>,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
456#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
457pub struct RunReceipt {
458 pub schema: String,
459 pub tool: ToolInfo,
460 pub run: RunMeta,
461 pub bench: BenchMeta,
462 pub samples: Vec<Sample>,
463 pub stats: Stats,
464}
465
466#[derive(
467 Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, Hash,
468)]
469#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
470#[serde(rename_all = "snake_case")]
471pub enum Metric {
472 BinaryBytes,
473 CpuMs,
474 CtxSwitches,
475 MaxRssKb,
476 PageFaults,
477 ThroughputPerS,
478 WallMs,
479}
480
481impl Metric {
482 pub fn as_str(self) -> &'static str {
493 match self {
494 Metric::BinaryBytes => "binary_bytes",
495 Metric::CpuMs => "cpu_ms",
496 Metric::CtxSwitches => "ctx_switches",
497 Metric::MaxRssKb => "max_rss_kb",
498 Metric::PageFaults => "page_faults",
499 Metric::ThroughputPerS => "throughput_per_s",
500 Metric::WallMs => "wall_ms",
501 }
502 }
503
504 pub fn parse_key(key: &str) -> Option<Self> {
516 match key {
517 "binary_bytes" => Some(Metric::BinaryBytes),
518 "cpu_ms" => Some(Metric::CpuMs),
519 "ctx_switches" => Some(Metric::CtxSwitches),
520 "max_rss_kb" => Some(Metric::MaxRssKb),
521 "page_faults" => Some(Metric::PageFaults),
522 "throughput_per_s" => Some(Metric::ThroughputPerS),
523 "wall_ms" => Some(Metric::WallMs),
524 _ => None,
525 }
526 }
527
528 pub fn default_direction(self) -> Direction {
542 match self {
543 Metric::BinaryBytes => Direction::Lower,
544 Metric::CpuMs => Direction::Lower,
545 Metric::CtxSwitches => Direction::Lower,
546 Metric::MaxRssKb => Direction::Lower,
547 Metric::PageFaults => Direction::Lower,
548 Metric::ThroughputPerS => Direction::Higher,
549 Metric::WallMs => Direction::Lower,
550 }
551 }
552
553 pub fn default_warn_factor(self) -> f64 {
554 0.9
556 }
557
558 pub fn display_unit(self) -> &'static str {
570 match self {
571 Metric::BinaryBytes => "bytes",
572 Metric::CpuMs => "ms",
573 Metric::CtxSwitches => "count",
574 Metric::MaxRssKb => "KB",
575 Metric::PageFaults => "count",
576 Metric::ThroughputPerS => "/s",
577 Metric::WallMs => "ms",
578 }
579 }
580}
581
582#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
583#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
584#[serde(rename_all = "snake_case")]
585pub enum MetricStatistic {
586 #[default]
587 Median,
588 P95,
589}
590
591impl MetricStatistic {
592 pub fn as_str(self) -> &'static str {
603 match self {
604 MetricStatistic::Median => "median",
605 MetricStatistic::P95 => "p95",
606 }
607 }
608}
609
610fn is_default_metric_statistic(stat: &MetricStatistic) -> bool {
611 *stat == MetricStatistic::Median
612}
613
614#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
615#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
616#[serde(rename_all = "snake_case")]
617pub enum Direction {
618 Lower,
619 Higher,
620}
621
622#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
623#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
624pub struct Budget {
625 pub threshold: f64,
627
628 pub warn_threshold: f64,
630
631 pub direction: Direction,
632}
633
634#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
635#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
636#[serde(rename_all = "snake_case")]
637pub enum SignificanceTest {
638 WelchT,
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
642#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
643pub struct Significance {
644 pub test: SignificanceTest,
645 pub p_value: f64,
646 pub alpha: f64,
647 pub significant: bool,
648 pub baseline_samples: u32,
649 pub current_samples: u32,
650}
651
652#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
653#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
654#[serde(rename_all = "snake_case")]
655pub enum MetricStatus {
656 Pass,
657 Warn,
658 Fail,
659}
660
661impl MetricStatus {
662 pub fn as_str(self) -> &'static str {
674 match self {
675 MetricStatus::Pass => "pass",
676 MetricStatus::Warn => "warn",
677 MetricStatus::Fail => "fail",
678 }
679 }
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
683#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
684pub struct Delta {
685 pub baseline: f64,
686 pub current: f64,
687
688 pub ratio: f64,
690
691 pub pct: f64,
693
694 pub regression: f64,
696
697 #[serde(default, skip_serializing_if = "is_default_metric_statistic")]
698 pub statistic: MetricStatistic,
699
700 #[serde(skip_serializing_if = "Option::is_none")]
701 pub significance: Option<Significance>,
702
703 pub status: MetricStatus,
704}
705
706#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
707#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
708pub struct CompareRef {
709 #[serde(skip_serializing_if = "Option::is_none")]
710 pub path: Option<String>,
711
712 #[serde(skip_serializing_if = "Option::is_none")]
713 pub run_id: Option<String>,
714}
715
716#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
717#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
718#[serde(rename_all = "snake_case")]
719pub enum VerdictStatus {
720 Pass,
721 Warn,
722 Fail,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
726#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
727pub struct VerdictCounts {
728 pub pass: u32,
729 pub warn: u32,
730 pub fail: u32,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
756#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
757pub struct Verdict {
758 pub status: VerdictStatus,
759 pub counts: VerdictCounts,
760 pub reasons: Vec<String>,
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
792#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
793pub struct CompareReceipt {
794 pub schema: String,
795 pub tool: ToolInfo,
796
797 pub bench: BenchMeta,
798
799 pub baseline_ref: CompareRef,
800 pub current_ref: CompareRef,
801
802 pub budgets: BTreeMap<Metric, Budget>,
803 pub deltas: BTreeMap<Metric, Delta>,
804
805 pub verdict: Verdict,
806}
807
808#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
814#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
815#[serde(rename_all = "snake_case")]
816pub enum Severity {
817 Warn,
818 Fail,
819}
820
821#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
823#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
824pub struct FindingData {
825 #[serde(rename = "metric_name")]
827 pub metric_name: String,
828
829 pub baseline: f64,
831
832 pub current: f64,
834
835 #[serde(rename = "regression_pct")]
837 pub regression_pct: f64,
838
839 pub threshold: f64,
841
842 pub direction: Direction,
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
848#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
849pub struct ReportFinding {
850 #[serde(rename = "check_id")]
852 pub check_id: String,
853
854 pub code: String,
856
857 pub severity: Severity,
859
860 pub message: String,
862
863 #[serde(skip_serializing_if = "Option::is_none")]
865 pub data: Option<FindingData>,
866}
867
868#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
870#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
871pub struct ReportSummary {
872 #[serde(rename = "pass_count")]
874 pub pass_count: u32,
875
876 #[serde(rename = "warn_count")]
878 pub warn_count: u32,
879
880 #[serde(rename = "fail_count")]
882 pub fail_count: u32,
883
884 #[serde(rename = "total_count")]
886 pub total_count: u32,
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
891#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
892pub struct PerfgateReport {
893 #[serde(rename = "report_type")]
895 pub report_type: String,
896
897 pub verdict: Verdict,
899
900 #[serde(skip_serializing_if = "Option::is_none")]
902 pub compare: Option<CompareReceipt>,
903
904 pub findings: Vec<ReportFinding>,
906
907 pub summary: ReportSummary,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
938#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
939pub struct ConfigFile {
940 #[serde(default)]
941 pub defaults: DefaultsConfig,
942
943 #[serde(default)]
945 pub baseline_server: BaselineServerConfig,
946
947 #[serde(default, rename = "bench")]
948 pub benches: Vec<BenchConfigFile>,
949}
950
951impl ConfigFile {
952 pub fn validate(&self) -> Result<(), String> {
974 for bench in &self.benches {
975 validate_bench_name(&bench.name).map_err(|e| e.to_string())?;
976 }
977 Ok(())
978 }
979}
980
981#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
982#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
983pub struct DefaultsConfig {
984 #[serde(skip_serializing_if = "Option::is_none", default)]
985 pub repeat: Option<u32>,
986
987 #[serde(skip_serializing_if = "Option::is_none", default)]
988 pub warmup: Option<u32>,
989
990 #[serde(skip_serializing_if = "Option::is_none", default)]
991 pub threshold: Option<f64>,
992
993 #[serde(skip_serializing_if = "Option::is_none", default)]
994 pub warn_factor: Option<f64>,
995
996 #[serde(skip_serializing_if = "Option::is_none", default)]
997 pub out_dir: Option<String>,
998
999 #[serde(skip_serializing_if = "Option::is_none", default)]
1000 pub baseline_dir: Option<String>,
1001
1002 #[serde(skip_serializing_if = "Option::is_none", default)]
1005 pub baseline_pattern: Option<String>,
1006
1007 #[serde(skip_serializing_if = "Option::is_none", default)]
1009 pub markdown_template: Option<String>,
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1027#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1028pub struct BaselineServerConfig {
1029 #[serde(skip_serializing_if = "Option::is_none", default)]
1031 pub url: Option<String>,
1032
1033 #[serde(skip_serializing_if = "Option::is_none", default)]
1035 pub api_key: Option<String>,
1036
1037 #[serde(skip_serializing_if = "Option::is_none", default)]
1039 pub project: Option<String>,
1040
1041 #[serde(default = "default_fallback_to_local")]
1043 pub fallback_to_local: bool,
1044}
1045
1046fn default_fallback_to_local() -> bool {
1047 true
1048}
1049
1050impl BaselineServerConfig {
1051 pub fn is_configured(&self) -> bool {
1053 self.url.is_some() && !self.url.as_ref().unwrap().is_empty()
1054 }
1055
1056 pub fn resolved_url(&self) -> Option<String> {
1058 std::env::var("PERFGATE_SERVER_URL")
1059 .ok()
1060 .or_else(|| self.url.clone())
1061 }
1062
1063 pub fn resolved_api_key(&self) -> Option<String> {
1065 std::env::var("PERFGATE_API_KEY")
1066 .ok()
1067 .or_else(|| self.api_key.clone())
1068 }
1069
1070 pub fn resolved_project(&self) -> Option<String> {
1072 std::env::var("PERFGATE_PROJECT")
1073 .ok()
1074 .or_else(|| self.project.clone())
1075 }
1076}
1077
1078#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1079#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1080pub struct BenchConfigFile {
1081 pub name: String,
1082
1083 #[serde(skip_serializing_if = "Option::is_none")]
1084 pub cwd: Option<String>,
1085
1086 #[serde(skip_serializing_if = "Option::is_none")]
1087 pub work: Option<u64>,
1088
1089 #[serde(skip_serializing_if = "Option::is_none")]
1091 pub timeout: Option<String>,
1092
1093 pub command: Vec<String>,
1095
1096 #[serde(skip_serializing_if = "Option::is_none")]
1098 pub repeat: Option<u32>,
1099
1100 #[serde(skip_serializing_if = "Option::is_none")]
1102 pub warmup: Option<u32>,
1103
1104 #[serde(skip_serializing_if = "Option::is_none")]
1105 pub metrics: Option<Vec<Metric>>,
1106
1107 #[serde(skip_serializing_if = "Option::is_none")]
1108 pub budgets: Option<BTreeMap<Metric, BudgetOverride>>,
1109}
1110
1111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1112#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1113pub struct BudgetOverride {
1114 #[serde(skip_serializing_if = "Option::is_none")]
1115 pub threshold: Option<f64>,
1116
1117 #[serde(skip_serializing_if = "Option::is_none")]
1118 pub direction: Option<Direction>,
1119
1120 #[serde(skip_serializing_if = "Option::is_none")]
1122 pub warn_factor: Option<f64>,
1123
1124 #[serde(skip_serializing_if = "Option::is_none")]
1125 pub statistic: Option<MetricStatistic>,
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130 use super::*;
1131
1132 #[test]
1133 fn metric_serde_keys_are_snake_case() {
1134 let mut m = BTreeMap::new();
1135 m.insert(
1136 Metric::WallMs,
1137 Budget {
1138 threshold: 0.2,
1139 warn_threshold: 0.18,
1140 direction: Direction::Lower,
1141 },
1142 );
1143 let json = serde_json::to_string(&m).unwrap();
1144 assert!(json.contains("\"wall_ms\""));
1145 }
1146
1147 #[test]
1148 fn metric_metadata_and_parsing_are_consistent() {
1149 let cases = [
1150 (
1151 Metric::BinaryBytes,
1152 "binary_bytes",
1153 Direction::Lower,
1154 "bytes",
1155 ),
1156 (Metric::WallMs, "wall_ms", Direction::Lower, "ms"),
1157 (Metric::CpuMs, "cpu_ms", Direction::Lower, "ms"),
1158 (
1159 Metric::CtxSwitches,
1160 "ctx_switches",
1161 Direction::Lower,
1162 "count",
1163 ),
1164 (Metric::MaxRssKb, "max_rss_kb", Direction::Lower, "KB"),
1165 (Metric::PageFaults, "page_faults", Direction::Lower, "count"),
1166 (
1167 Metric::ThroughputPerS,
1168 "throughput_per_s",
1169 Direction::Higher,
1170 "/s",
1171 ),
1172 ];
1173
1174 for (metric, key, direction, unit) in cases {
1175 assert_eq!(metric.as_str(), key);
1176 assert_eq!(Metric::parse_key(key), Some(metric));
1177 assert_eq!(metric.default_direction(), direction);
1178 assert_eq!(metric.display_unit(), unit);
1179 assert!((metric.default_warn_factor() - 0.9).abs() < f64::EPSILON);
1180 }
1181
1182 assert!(Metric::parse_key("unknown").is_none());
1183
1184 assert_eq!(MetricStatistic::Median.as_str(), "median");
1185 assert_eq!(MetricStatistic::P95.as_str(), "p95");
1186 assert!(is_default_metric_statistic(&MetricStatistic::Median));
1187 assert!(!is_default_metric_statistic(&MetricStatistic::P95));
1188 }
1189
1190 #[test]
1191 fn status_and_policy_as_str_values() {
1192 assert_eq!(MetricStatus::Pass.as_str(), "pass");
1193 assert_eq!(MetricStatus::Warn.as_str(), "warn");
1194 assert_eq!(MetricStatus::Fail.as_str(), "fail");
1195
1196 assert_eq!(HostMismatchPolicy::Warn.as_str(), "warn");
1197 assert_eq!(HostMismatchPolicy::Error.as_str(), "error");
1198 assert_eq!(HostMismatchPolicy::Ignore.as_str(), "ignore");
1199 }
1200
1201 #[test]
1203 fn backward_compat_host_info_without_new_fields() {
1204 let json = r#"{"os":"linux","arch":"x86_64"}"#;
1206 let info: HostInfo = serde_json::from_str(json).expect("should parse old format");
1207 assert_eq!(info.os, "linux");
1208 assert_eq!(info.arch, "x86_64");
1209 assert!(info.cpu_count.is_none());
1210 assert!(info.memory_bytes.is_none());
1211 assert!(info.hostname_hash.is_none());
1212 }
1213
1214 #[test]
1215 fn host_info_minimal_json_snapshot() {
1216 let info = HostInfo {
1217 os: "linux".to_string(),
1218 arch: "x86_64".to_string(),
1219 cpu_count: None,
1220 memory_bytes: None,
1221 hostname_hash: None,
1222 };
1223
1224 let value = serde_json::to_value(&info).expect("serialize HostInfo");
1225 insta::assert_json_snapshot!(value, @r###"
1226 {
1227 "arch": "x86_64",
1228 "os": "linux"
1229 }
1230 "###);
1231 }
1232
1233 #[test]
1235 fn host_info_with_new_fields_serializes() {
1236 let info = HostInfo {
1237 os: "linux".to_string(),
1238 arch: "x86_64".to_string(),
1239 cpu_count: Some(8),
1240 memory_bytes: Some(16 * 1024 * 1024 * 1024),
1241 hostname_hash: Some("abc123".to_string()),
1242 };
1243
1244 let json = serde_json::to_string(&info).expect("should serialize");
1245 assert!(json.contains("\"cpu_count\":8"));
1246 assert!(json.contains("\"memory_bytes\":"));
1247 assert!(json.contains("\"hostname_hash\":\"abc123\""));
1248 }
1249
1250 #[test]
1252 fn host_info_omits_none_fields() {
1253 let info = HostInfo {
1254 os: "linux".to_string(),
1255 arch: "x86_64".to_string(),
1256 cpu_count: None,
1257 memory_bytes: None,
1258 hostname_hash: None,
1259 };
1260
1261 let json = serde_json::to_string(&info).expect("should serialize");
1262 assert!(!json.contains("cpu_count"));
1263 assert!(!json.contains("memory_bytes"));
1264 assert!(!json.contains("hostname_hash"));
1265 }
1266
1267 #[test]
1269 fn host_info_round_trip_with_all_fields() {
1270 let original = HostInfo {
1271 os: "macos".to_string(),
1272 arch: "aarch64".to_string(),
1273 cpu_count: Some(10),
1274 memory_bytes: Some(32 * 1024 * 1024 * 1024),
1275 hostname_hash: Some("deadbeef".repeat(8)),
1276 };
1277
1278 let json = serde_json::to_string(&original).expect("should serialize");
1279 let parsed: HostInfo = serde_json::from_str(&json).expect("should deserialize");
1280
1281 assert_eq!(original, parsed);
1282 }
1283
1284 #[test]
1285 fn validate_bench_name_valid() {
1286 assert!(validate_bench_name("my-bench").is_ok());
1287 assert!(validate_bench_name("bench_a").is_ok());
1288 assert!(validate_bench_name("path/to/bench").is_ok());
1289 assert!(validate_bench_name("bench.v2").is_ok());
1290 assert!(validate_bench_name("a").is_ok());
1291 assert!(validate_bench_name("123").is_ok());
1292 }
1293
1294 #[test]
1295 fn validate_bench_name_invalid() {
1296 assert!(validate_bench_name("bench|name").is_err());
1297 assert!(validate_bench_name("").is_err());
1298 assert!(validate_bench_name("bench name").is_err());
1299 assert!(validate_bench_name("bench@name").is_err());
1300 }
1301
1302 #[test]
1303 fn validate_bench_name_path_traversal() {
1304 assert!(validate_bench_name("../bench").is_err());
1305 assert!(validate_bench_name("bench/../x").is_err());
1306 assert!(validate_bench_name("./bench").is_err());
1307 assert!(validate_bench_name("bench/.").is_err());
1308 }
1309
1310 #[test]
1311 fn validate_bench_name_empty_segments() {
1312 assert!(validate_bench_name("/bench").is_err());
1313 assert!(validate_bench_name("bench/").is_err());
1314 assert!(validate_bench_name("bench//x").is_err());
1315 assert!(validate_bench_name("/").is_err());
1316 }
1317
1318 #[test]
1319 fn validate_bench_name_length_cap() {
1320 let name_64 = "a".repeat(BENCH_NAME_MAX_LEN);
1322 assert!(validate_bench_name(&name_64).is_ok());
1323
1324 let name_65 = "a".repeat(BENCH_NAME_MAX_LEN + 1);
1326 assert!(validate_bench_name(&name_65).is_err());
1327 }
1328
1329 #[test]
1330 fn validate_bench_name_case() {
1331 assert!(validate_bench_name("MyBench").is_err());
1332 assert!(validate_bench_name("BENCH").is_err());
1333 assert!(validate_bench_name("benchA").is_err());
1334 }
1335
1336 #[test]
1337 fn config_file_validate_catches_bad_bench_name() {
1338 let config = ConfigFile {
1339 defaults: DefaultsConfig::default(),
1340 baseline_server: BaselineServerConfig::default(),
1341 benches: vec![BenchConfigFile {
1342 name: "bad|name".to_string(),
1343 cwd: None,
1344 work: None,
1345 timeout: None,
1346 command: vec!["echo".to_string()],
1347 repeat: None,
1348 warmup: None,
1349 metrics: None,
1350 budgets: None,
1351 }],
1352 };
1353 assert!(config.validate().is_err());
1354 }
1355
1356 #[test]
1357 fn perfgate_error_display_baseline_resolve() {
1358 let err = PerfgateError::BaselineResolve("file not found".to_string());
1359 assert_eq!(format!("{}", err), "baseline resolve: file not found");
1360 }
1361
1362 #[test]
1363 fn perfgate_error_display_artifact_write() {
1364 let err = PerfgateError::ArtifactWrite("permission denied".to_string());
1365 assert_eq!(format!("{}", err), "write artifacts: permission denied");
1366 }
1367
1368 #[test]
1369 fn perfgate_error_display_run_command() {
1370 let err = PerfgateError::RunCommand("spawn failed".to_string());
1371 assert_eq!(format!("{}", err), "run command: spawn failed");
1372 }
1373
1374 #[test]
1375 fn sensor_capabilities_backward_compat_without_engine() {
1376 let json = r#"{"baseline":{"status":"available"}}"#;
1377 let caps: SensorCapabilities =
1378 serde_json::from_str(json).expect("should parse without engine");
1379 assert_eq!(caps.baseline.status, CapabilityStatus::Available);
1380 assert!(caps.engine.is_none());
1381 }
1382
1383 #[test]
1384 fn sensor_capabilities_with_engine() {
1385 let caps = SensorCapabilities {
1386 baseline: Capability {
1387 status: CapabilityStatus::Available,
1388 reason: None,
1389 },
1390 engine: Some(Capability {
1391 status: CapabilityStatus::Available,
1392 reason: None,
1393 }),
1394 };
1395 let json = serde_json::to_string(&caps).unwrap();
1396 assert!(json.contains("\"engine\""));
1397 let parsed: SensorCapabilities = serde_json::from_str(&json).unwrap();
1398 assert_eq!(caps, parsed);
1399 }
1400
1401 #[test]
1402 fn sensor_capabilities_engine_omitted_when_none() {
1403 let caps = SensorCapabilities {
1404 baseline: Capability {
1405 status: CapabilityStatus::Available,
1406 reason: None,
1407 },
1408 engine: None,
1409 };
1410 let json = serde_json::to_string(&caps).unwrap();
1411 assert!(!json.contains("engine"));
1412 }
1413
1414 #[test]
1415 fn config_file_validate_passes_good_bench_names() {
1416 let config = ConfigFile {
1417 defaults: DefaultsConfig::default(),
1418 baseline_server: BaselineServerConfig::default(),
1419 benches: vec![BenchConfigFile {
1420 name: "my-bench".to_string(),
1421 cwd: None,
1422 work: None,
1423 timeout: None,
1424 command: vec!["echo".to_string()],
1425 repeat: None,
1426 warmup: None,
1427 metrics: None,
1428 budgets: None,
1429 }],
1430 };
1431 assert!(config.validate().is_ok());
1432 }
1433
1434 #[test]
1437 fn run_receipt_serde_roundtrip_typical() {
1438 let receipt = RunReceipt {
1439 schema: RUN_SCHEMA_V1.to_string(),
1440 tool: ToolInfo {
1441 name: "perfgate".into(),
1442 version: "1.2.3".into(),
1443 },
1444 run: RunMeta {
1445 id: "abc-123".into(),
1446 started_at: "2024-06-15T10:00:00Z".into(),
1447 ended_at: "2024-06-15T10:00:05Z".into(),
1448 host: HostInfo {
1449 os: "linux".into(),
1450 arch: "x86_64".into(),
1451 cpu_count: Some(8),
1452 memory_bytes: Some(16_000_000_000),
1453 hostname_hash: Some("cafebabe".into()),
1454 },
1455 },
1456 bench: BenchMeta {
1457 name: "my-bench".into(),
1458 cwd: Some("/tmp".into()),
1459 command: vec!["echo".into(), "hello".into()],
1460 repeat: 5,
1461 warmup: 1,
1462 work_units: Some(1000),
1463 timeout_ms: Some(30000),
1464 },
1465 samples: vec![
1466 Sample {
1467 wall_ms: 100,
1468 exit_code: 0,
1469 warmup: true,
1470 timed_out: false,
1471 cpu_ms: Some(80),
1472 page_faults: Some(10),
1473 ctx_switches: Some(5),
1474 max_rss_kb: Some(2048),
1475 binary_bytes: Some(4096),
1476 stdout: Some("ok".into()),
1477 stderr: None,
1478 },
1479 Sample {
1480 wall_ms: 95,
1481 exit_code: 0,
1482 warmup: false,
1483 timed_out: false,
1484 cpu_ms: Some(75),
1485 page_faults: None,
1486 ctx_switches: None,
1487 max_rss_kb: Some(2000),
1488 binary_bytes: None,
1489 stdout: None,
1490 stderr: Some("warn".into()),
1491 },
1492 ],
1493 stats: Stats {
1494 wall_ms: U64Summary {
1495 median: 95,
1496 min: 90,
1497 max: 100,
1498 },
1499 cpu_ms: Some(U64Summary {
1500 median: 75,
1501 min: 70,
1502 max: 80,
1503 }),
1504 page_faults: Some(U64Summary {
1505 median: 10,
1506 min: 10,
1507 max: 10,
1508 }),
1509 ctx_switches: Some(U64Summary {
1510 median: 5,
1511 min: 5,
1512 max: 5,
1513 }),
1514 max_rss_kb: Some(U64Summary {
1515 median: 2048,
1516 min: 2000,
1517 max: 2100,
1518 }),
1519 binary_bytes: Some(U64Summary {
1520 median: 4096,
1521 min: 4096,
1522 max: 4096,
1523 }),
1524 throughput_per_s: Some(F64Summary {
1525 median: 10.526,
1526 min: 10.0,
1527 max: 11.111,
1528 }),
1529 },
1530 };
1531 let json = serde_json::to_string(&receipt).unwrap();
1532 let back: RunReceipt = serde_json::from_str(&json).unwrap();
1533 assert_eq!(receipt, back);
1534 }
1535
1536 #[test]
1537 fn run_receipt_serde_roundtrip_edge_empty_samples() {
1538 let receipt = RunReceipt {
1539 schema: RUN_SCHEMA_V1.to_string(),
1540 tool: ToolInfo {
1541 name: "p".into(),
1542 version: "0".into(),
1543 },
1544 run: RunMeta {
1545 id: "".into(),
1546 started_at: "".into(),
1547 ended_at: "".into(),
1548 host: HostInfo {
1549 os: "".into(),
1550 arch: "".into(),
1551 cpu_count: None,
1552 memory_bytes: None,
1553 hostname_hash: None,
1554 },
1555 },
1556 bench: BenchMeta {
1557 name: "b".into(),
1558 cwd: None,
1559 command: vec![],
1560 repeat: 0,
1561 warmup: 0,
1562 work_units: None,
1563 timeout_ms: None,
1564 },
1565 samples: vec![],
1566 stats: Stats {
1567 wall_ms: U64Summary {
1568 median: 0,
1569 min: 0,
1570 max: 0,
1571 },
1572 cpu_ms: None,
1573 page_faults: None,
1574 ctx_switches: None,
1575 max_rss_kb: None,
1576 binary_bytes: None,
1577 throughput_per_s: None,
1578 },
1579 };
1580 let json = serde_json::to_string(&receipt).unwrap();
1581 let back: RunReceipt = serde_json::from_str(&json).unwrap();
1582 assert_eq!(receipt, back);
1583 }
1584
1585 #[test]
1586 fn run_receipt_serde_roundtrip_edge_large_values() {
1587 let receipt = RunReceipt {
1588 schema: RUN_SCHEMA_V1.to_string(),
1589 tool: ToolInfo {
1590 name: "perfgate".into(),
1591 version: "99.99.99".into(),
1592 },
1593 run: RunMeta {
1594 id: "max-run".into(),
1595 started_at: "2099-12-31T23:59:59Z".into(),
1596 ended_at: "2099-12-31T23:59:59Z".into(),
1597 host: HostInfo {
1598 os: "linux".into(),
1599 arch: "aarch64".into(),
1600 cpu_count: Some(u32::MAX),
1601 memory_bytes: Some(u64::MAX),
1602 hostname_hash: None,
1603 },
1604 },
1605 bench: BenchMeta {
1606 name: "big".into(),
1607 cwd: None,
1608 command: vec!["run".into()],
1609 repeat: u32::MAX,
1610 warmup: u32::MAX,
1611 work_units: Some(u64::MAX),
1612 timeout_ms: Some(u64::MAX),
1613 },
1614 samples: vec![Sample {
1615 wall_ms: u64::MAX,
1616 exit_code: i32::MIN,
1617 warmup: false,
1618 timed_out: true,
1619 cpu_ms: Some(u64::MAX),
1620 page_faults: Some(u64::MAX),
1621 ctx_switches: Some(u64::MAX),
1622 max_rss_kb: Some(u64::MAX),
1623 binary_bytes: Some(u64::MAX),
1624 stdout: None,
1625 stderr: None,
1626 }],
1627 stats: Stats {
1628 wall_ms: U64Summary {
1629 median: u64::MAX,
1630 min: 0,
1631 max: u64::MAX,
1632 },
1633 cpu_ms: None,
1634 page_faults: None,
1635 ctx_switches: None,
1636 max_rss_kb: None,
1637 binary_bytes: None,
1638 throughput_per_s: Some(F64Summary {
1639 median: f64::MAX,
1640 min: 0.0,
1641 max: f64::MAX,
1642 }),
1643 },
1644 };
1645 let json = serde_json::to_string(&receipt).unwrap();
1646 let back: RunReceipt = serde_json::from_str(&json).unwrap();
1647 assert_eq!(receipt, back);
1648 }
1649
1650 #[test]
1651 fn compare_receipt_serde_roundtrip_typical() {
1652 let mut budgets = BTreeMap::new();
1653 budgets.insert(
1654 Metric::WallMs,
1655 Budget {
1656 threshold: 0.2,
1657 warn_threshold: 0.18,
1658 direction: Direction::Lower,
1659 },
1660 );
1661 budgets.insert(
1662 Metric::MaxRssKb,
1663 Budget {
1664 threshold: 0.15,
1665 warn_threshold: 0.1,
1666 direction: Direction::Lower,
1667 },
1668 );
1669
1670 let mut deltas = BTreeMap::new();
1671 deltas.insert(
1672 Metric::WallMs,
1673 Delta {
1674 baseline: 1000.0,
1675 current: 1100.0,
1676 ratio: 1.1,
1677 pct: 0.1,
1678 regression: 0.1,
1679 statistic: MetricStatistic::Median,
1680 significance: None,
1681 status: MetricStatus::Pass,
1682 },
1683 );
1684 deltas.insert(
1685 Metric::MaxRssKb,
1686 Delta {
1687 baseline: 2048.0,
1688 current: 2500.0,
1689 ratio: 1.2207,
1690 pct: 0.2207,
1691 regression: 0.2207,
1692 statistic: MetricStatistic::Median,
1693 significance: None,
1694 status: MetricStatus::Fail,
1695 },
1696 );
1697
1698 let receipt = CompareReceipt {
1699 schema: COMPARE_SCHEMA_V1.to_string(),
1700 tool: ToolInfo {
1701 name: "perfgate".into(),
1702 version: "1.0.0".into(),
1703 },
1704 bench: BenchMeta {
1705 name: "test".into(),
1706 cwd: None,
1707 command: vec!["echo".into()],
1708 repeat: 5,
1709 warmup: 0,
1710 work_units: None,
1711 timeout_ms: None,
1712 },
1713 baseline_ref: CompareRef {
1714 path: Some("base.json".into()),
1715 run_id: Some("r1".into()),
1716 },
1717 current_ref: CompareRef {
1718 path: Some("cur.json".into()),
1719 run_id: Some("r2".into()),
1720 },
1721 budgets,
1722 deltas,
1723 verdict: Verdict {
1724 status: VerdictStatus::Fail,
1725 counts: VerdictCounts {
1726 pass: 1,
1727 warn: 0,
1728 fail: 1,
1729 },
1730 reasons: vec!["max_rss_kb_fail".into()],
1731 },
1732 };
1733 let json = serde_json::to_string(&receipt).unwrap();
1734 let back: CompareReceipt = serde_json::from_str(&json).unwrap();
1735 assert_eq!(receipt, back);
1736 }
1737
1738 #[test]
1739 fn compare_receipt_serde_roundtrip_edge_empty_maps() {
1740 let receipt = CompareReceipt {
1741 schema: COMPARE_SCHEMA_V1.to_string(),
1742 tool: ToolInfo {
1743 name: "p".into(),
1744 version: "0".into(),
1745 },
1746 bench: BenchMeta {
1747 name: "b".into(),
1748 cwd: None,
1749 command: vec![],
1750 repeat: 0,
1751 warmup: 0,
1752 work_units: None,
1753 timeout_ms: None,
1754 },
1755 baseline_ref: CompareRef {
1756 path: None,
1757 run_id: None,
1758 },
1759 current_ref: CompareRef {
1760 path: None,
1761 run_id: None,
1762 },
1763 budgets: BTreeMap::new(),
1764 deltas: BTreeMap::new(),
1765 verdict: Verdict {
1766 status: VerdictStatus::Pass,
1767 counts: VerdictCounts {
1768 pass: 0,
1769 warn: 0,
1770 fail: 0,
1771 },
1772 reasons: vec![],
1773 },
1774 };
1775 let json = serde_json::to_string(&receipt).unwrap();
1776 let back: CompareReceipt = serde_json::from_str(&json).unwrap();
1777 assert_eq!(receipt, back);
1778 }
1779
1780 #[test]
1781 fn report_receipt_serde_roundtrip() {
1782 let report = PerfgateReport {
1783 report_type: REPORT_SCHEMA_V1.to_string(),
1784 verdict: Verdict {
1785 status: VerdictStatus::Warn,
1786 counts: VerdictCounts {
1787 pass: 1,
1788 warn: 1,
1789 fail: 0,
1790 },
1791 reasons: vec!["wall_ms_warn".into()],
1792 },
1793 compare: None,
1794 findings: vec![ReportFinding {
1795 check_id: CHECK_ID_BUDGET.into(),
1796 code: FINDING_CODE_METRIC_WARN.into(),
1797 severity: Severity::Warn,
1798 message: "Performance regression near threshold for wall_ms".into(),
1799 data: Some(FindingData {
1800 metric_name: "wall_ms".into(),
1801 baseline: 100.0,
1802 current: 119.0,
1803 regression_pct: 0.19,
1804 threshold: 0.2,
1805 direction: Direction::Lower,
1806 }),
1807 }],
1808 summary: ReportSummary {
1809 pass_count: 1,
1810 warn_count: 1,
1811 fail_count: 0,
1812 total_count: 2,
1813 },
1814 };
1815 let json = serde_json::to_string(&report).unwrap();
1816 let back: PerfgateReport = serde_json::from_str(&json).unwrap();
1817 assert_eq!(report, back);
1818 }
1819
1820 #[test]
1821 fn config_file_serde_roundtrip_typical() {
1822 let config = ConfigFile {
1823 defaults: DefaultsConfig {
1824 repeat: Some(10),
1825 warmup: Some(2),
1826 threshold: Some(0.2),
1827 warn_factor: Some(0.9),
1828 out_dir: Some("artifacts/perfgate".into()),
1829 baseline_dir: Some("baselines".into()),
1830 baseline_pattern: Some("baselines/{bench}.json".into()),
1831 markdown_template: None,
1832 },
1833 baseline_server: BaselineServerConfig::default(),
1834 benches: vec![BenchConfigFile {
1835 name: "my-bench".into(),
1836 cwd: Some("/home/user/project".into()),
1837 work: Some(1000),
1838 timeout: Some("5s".into()),
1839 command: vec!["cargo".into(), "bench".into()],
1840 repeat: Some(20),
1841 warmup: Some(3),
1842 metrics: Some(vec![Metric::WallMs, Metric::MaxRssKb]),
1843 budgets: Some({
1844 let mut m = BTreeMap::new();
1845 m.insert(
1846 Metric::WallMs,
1847 BudgetOverride {
1848 threshold: Some(0.15),
1849 direction: Some(Direction::Lower),
1850 warn_factor: Some(0.85),
1851 statistic: Some(MetricStatistic::P95),
1852 },
1853 );
1854 m
1855 }),
1856 }],
1857 };
1858 let json = serde_json::to_string(&config).unwrap();
1859 let back: ConfigFile = serde_json::from_str(&json).unwrap();
1860 assert_eq!(config, back);
1861 }
1862
1863 #[test]
1864 fn config_file_serde_roundtrip_edge_empty() {
1865 let config = ConfigFile {
1866 defaults: DefaultsConfig::default(),
1867 baseline_server: BaselineServerConfig::default(),
1868 benches: vec![],
1869 };
1870 let json = serde_json::to_string(&config).unwrap();
1871 let back: ConfigFile = serde_json::from_str(&json).unwrap();
1872 assert_eq!(config, back);
1873 }
1874
1875 #[test]
1876 fn stats_serde_roundtrip_all_fields() {
1877 let stats = Stats {
1878 wall_ms: U64Summary {
1879 median: 500,
1880 min: 100,
1881 max: 900,
1882 },
1883 cpu_ms: Some(U64Summary {
1884 median: 400,
1885 min: 80,
1886 max: 800,
1887 }),
1888 page_faults: Some(U64Summary {
1889 median: 50,
1890 min: 10,
1891 max: 100,
1892 }),
1893 ctx_switches: Some(U64Summary {
1894 median: 20,
1895 min: 5,
1896 max: 40,
1897 }),
1898 max_rss_kb: Some(U64Summary {
1899 median: 4096,
1900 min: 2048,
1901 max: 8192,
1902 }),
1903 binary_bytes: Some(U64Summary {
1904 median: 1024,
1905 min: 1024,
1906 max: 1024,
1907 }),
1908 throughput_per_s: Some(F64Summary {
1909 median: 2.0,
1910 min: 1.111,
1911 max: 10.0,
1912 }),
1913 };
1914 let json = serde_json::to_string(&stats).unwrap();
1915 let back: Stats = serde_json::from_str(&json).unwrap();
1916 assert_eq!(stats, back);
1917 }
1918
1919 #[test]
1920 fn stats_serde_roundtrip_edge_zeros() {
1921 let stats = Stats {
1922 wall_ms: U64Summary {
1923 median: 0,
1924 min: 0,
1925 max: 0,
1926 },
1927 cpu_ms: None,
1928 page_faults: None,
1929 ctx_switches: None,
1930 max_rss_kb: None,
1931 binary_bytes: None,
1932 throughput_per_s: Some(F64Summary {
1933 median: 0.0,
1934 min: 0.0,
1935 max: 0.0,
1936 }),
1937 };
1938 let json = serde_json::to_string(&stats).unwrap();
1939 let back: Stats = serde_json::from_str(&json).unwrap();
1940 assert_eq!(stats, back);
1941 }
1942
1943 #[test]
1944 fn backward_compat_run_receipt_missing_host_extensions() {
1945 let json = r#"{
1947 "schema": "perfgate.run.v1",
1948 "tool": {"name": "perfgate", "version": "0.0.1"},
1949 "run": {
1950 "id": "old-run",
1951 "started_at": "2023-06-01T00:00:00Z",
1952 "ended_at": "2023-06-01T00:01:00Z",
1953 "host": {"os": "macos", "arch": "aarch64"}
1954 },
1955 "bench": {
1956 "name": "legacy",
1957 "command": ["./bench"],
1958 "repeat": 1,
1959 "warmup": 0
1960 },
1961 "samples": [{"wall_ms": 50, "exit_code": 0}],
1962 "stats": {
1963 "wall_ms": {"median": 50, "min": 50, "max": 50}
1964 }
1965 }"#;
1966
1967 let receipt: RunReceipt =
1968 serde_json::from_str(json).expect("old format without host extensions");
1969 assert_eq!(receipt.run.host.os, "macos");
1970 assert_eq!(receipt.run.host.arch, "aarch64");
1971 assert!(receipt.run.host.cpu_count.is_none());
1972 assert!(receipt.run.host.memory_bytes.is_none());
1973 assert!(receipt.run.host.hostname_hash.is_none());
1974 assert_eq!(receipt.bench.name, "legacy");
1975 assert_eq!(receipt.samples.len(), 1);
1976 assert!(!receipt.samples[0].warmup);
1977 assert!(!receipt.samples[0].timed_out);
1978 }
1979
1980 #[test]
1981 fn backward_compat_compare_receipt_without_significance() {
1982 let json = r#"{
1983 "schema": "perfgate.compare.v1",
1984 "tool": {"name": "perfgate", "version": "0.0.1"},
1985 "bench": {
1986 "name": "old-cmp",
1987 "command": ["echo"],
1988 "repeat": 3,
1989 "warmup": 0
1990 },
1991 "baseline_ref": {"path": "base.json"},
1992 "current_ref": {"path": "cur.json"},
1993 "budgets": {
1994 "wall_ms": {"threshold": 0.2, "warn_threshold": 0.1, "direction": "lower"}
1995 },
1996 "deltas": {
1997 "wall_ms": {
1998 "baseline": 100.0,
1999 "current": 105.0,
2000 "ratio": 1.05,
2001 "pct": 0.05,
2002 "regression": 0.05,
2003 "status": "pass"
2004 }
2005 },
2006 "verdict": {
2007 "status": "pass",
2008 "counts": {"pass": 1, "warn": 0, "fail": 0},
2009 "reasons": []
2010 }
2011 }"#;
2012
2013 let receipt: CompareReceipt =
2014 serde_json::from_str(json).expect("compare without significance");
2015 assert_eq!(receipt.deltas.len(), 1);
2016 let delta = receipt.deltas.get(&Metric::WallMs).unwrap();
2017 assert!(delta.significance.is_none());
2018 assert_eq!(delta.statistic, MetricStatistic::Median); assert_eq!(delta.status, MetricStatus::Pass);
2020 }
2021
2022 #[test]
2023 fn backward_compat_unknown_fields_are_ignored() {
2024 let json = r#"{
2025 "schema": "perfgate.run.v1",
2026 "tool": {"name": "perfgate", "version": "0.1.0"},
2027 "run": {
2028 "id": "test",
2029 "started_at": "2024-01-01T00:00:00Z",
2030 "ended_at": "2024-01-01T00:01:00Z",
2031 "host": {"os": "linux", "arch": "x86_64", "future_field": "ignored"}
2032 },
2033 "bench": {
2034 "name": "test",
2035 "command": ["echo"],
2036 "repeat": 1,
2037 "warmup": 0,
2038 "some_new_option": true
2039 },
2040 "samples": [{"wall_ms": 10, "exit_code": 0, "extra_metric": 42}],
2041 "stats": {
2042 "wall_ms": {"median": 10, "min": 10, "max": 10},
2043 "new_metric": {"median": 1, "min": 1, "max": 1}
2044 },
2045 "new_top_level_field": "should be ignored"
2046 }"#;
2047
2048 let receipt: RunReceipt =
2049 serde_json::from_str(json).expect("unknown fields should be ignored");
2050 assert_eq!(receipt.bench.name, "test");
2051 assert_eq!(receipt.samples.len(), 1);
2052 }
2053
2054 #[test]
2055 fn roundtrip_run_receipt_all_optionals_none() {
2056 let receipt = RunReceipt {
2057 schema: RUN_SCHEMA_V1.to_string(),
2058 tool: ToolInfo {
2059 name: "perfgate".into(),
2060 version: "0.1.0".into(),
2061 },
2062 run: RunMeta {
2063 id: "rt".into(),
2064 started_at: "2024-01-01T00:00:00Z".into(),
2065 ended_at: "2024-01-01T00:01:00Z".into(),
2066 host: HostInfo {
2067 os: "linux".into(),
2068 arch: "x86_64".into(),
2069 cpu_count: None,
2070 memory_bytes: None,
2071 hostname_hash: None,
2072 },
2073 },
2074 bench: BenchMeta {
2075 name: "minimal".into(),
2076 cwd: None,
2077 command: vec!["true".into()],
2078 repeat: 1,
2079 warmup: 0,
2080 work_units: None,
2081 timeout_ms: None,
2082 },
2083 samples: vec![Sample {
2084 wall_ms: 1,
2085 exit_code: 0,
2086 warmup: false,
2087 timed_out: false,
2088 cpu_ms: None,
2089 page_faults: None,
2090 ctx_switches: None,
2091 max_rss_kb: None,
2092 binary_bytes: None,
2093 stdout: None,
2094 stderr: None,
2095 }],
2096 stats: Stats {
2097 wall_ms: U64Summary {
2098 median: 1,
2099 min: 1,
2100 max: 1,
2101 },
2102 cpu_ms: None,
2103 page_faults: None,
2104 ctx_switches: None,
2105 max_rss_kb: None,
2106 binary_bytes: None,
2107 throughput_per_s: None,
2108 },
2109 };
2110
2111 let json = serde_json::to_string(&receipt).unwrap();
2112 let back: RunReceipt = serde_json::from_str(&json).unwrap();
2113 assert_eq!(receipt, back);
2114
2115 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
2117 let host = &value["run"]["host"];
2118 assert!(host.get("cpu_count").is_none());
2119 assert!(host.get("memory_bytes").is_none());
2120 assert!(host.get("hostname_hash").is_none());
2121 }
2122
2123 #[test]
2124 fn roundtrip_compare_receipt_all_optionals_none() {
2125 let receipt = CompareReceipt {
2126 schema: COMPARE_SCHEMA_V1.to_string(),
2127 tool: ToolInfo {
2128 name: "perfgate".into(),
2129 version: "0.1.0".into(),
2130 },
2131 bench: BenchMeta {
2132 name: "minimal".into(),
2133 cwd: None,
2134 command: vec!["true".into()],
2135 repeat: 1,
2136 warmup: 0,
2137 work_units: None,
2138 timeout_ms: None,
2139 },
2140 baseline_ref: CompareRef {
2141 path: None,
2142 run_id: None,
2143 },
2144 current_ref: CompareRef {
2145 path: None,
2146 run_id: None,
2147 },
2148 budgets: BTreeMap::new(),
2149 deltas: BTreeMap::new(),
2150 verdict: Verdict {
2151 status: VerdictStatus::Pass,
2152 counts: VerdictCounts {
2153 pass: 0,
2154 warn: 0,
2155 fail: 0,
2156 },
2157 reasons: vec![],
2158 },
2159 };
2160
2161 let json = serde_json::to_string(&receipt).unwrap();
2162 let back: CompareReceipt = serde_json::from_str(&json).unwrap();
2163 assert_eq!(receipt, back);
2164 }
2165
2166 #[test]
2168 fn backward_compat_run_receipt_old_format() {
2169 let json = r#"{
2170 "schema": "perfgate.run.v1",
2171 "tool": {"name": "perfgate", "version": "0.1.0"},
2172 "run": {
2173 "id": "test-id",
2174 "started_at": "2024-01-01T00:00:00Z",
2175 "ended_at": "2024-01-01T00:01:00Z",
2176 "host": {"os": "linux", "arch": "x86_64"}
2177 },
2178 "bench": {
2179 "name": "test",
2180 "command": ["echo", "hello"],
2181 "repeat": 5,
2182 "warmup": 0
2183 },
2184 "samples": [{"wall_ms": 100, "exit_code": 0}],
2185 "stats": {
2186 "wall_ms": {"median": 100, "min": 90, "max": 110}
2187 }
2188 }"#;
2189
2190 let receipt: RunReceipt = serde_json::from_str(json).expect("should parse old format");
2191 assert_eq!(receipt.run.host.os, "linux");
2192 assert_eq!(receipt.run.host.arch, "x86_64");
2193 assert!(receipt.run.host.cpu_count.is_none());
2194 assert!(receipt.run.host.memory_bytes.is_none());
2195 assert!(receipt.run.host.hostname_hash.is_none());
2196 }
2197}
2198
2199#[cfg(test)]
2200mod property_tests {
2201 use super::*;
2202 use proptest::prelude::*;
2203
2204 fn non_empty_string() -> impl Strategy<Value = String> {
2206 "[a-zA-Z0-9_-]{1,20}".prop_map(|s| s)
2207 }
2208
2209 fn rfc3339_timestamp() -> impl Strategy<Value = String> {
2211 (
2212 2020u32..2030,
2213 1u32..13,
2214 1u32..29,
2215 0u32..24,
2216 0u32..60,
2217 0u32..60,
2218 )
2219 .prop_map(|(year, month, day, hour, min, sec)| {
2220 format!(
2221 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
2222 year, month, day, hour, min, sec
2223 )
2224 })
2225 }
2226
2227 fn tool_info_strategy() -> impl Strategy<Value = ToolInfo> {
2229 (non_empty_string(), non_empty_string())
2230 .prop_map(|(name, version)| ToolInfo { name, version })
2231 }
2232
2233 fn host_info_strategy() -> impl Strategy<Value = HostInfo> {
2235 (
2236 non_empty_string(),
2237 non_empty_string(),
2238 proptest::option::of(1u32..256),
2239 proptest::option::of(1u64..68719476736), proptest::option::of("[a-f0-9]{64}"), )
2242 .prop_map(
2243 |(os, arch, cpu_count, memory_bytes, hostname_hash)| HostInfo {
2244 os,
2245 arch,
2246 cpu_count,
2247 memory_bytes,
2248 hostname_hash,
2249 },
2250 )
2251 }
2252
2253 fn run_meta_strategy() -> impl Strategy<Value = RunMeta> {
2255 (
2256 non_empty_string(),
2257 rfc3339_timestamp(),
2258 rfc3339_timestamp(),
2259 host_info_strategy(),
2260 )
2261 .prop_map(|(id, started_at, ended_at, host)| RunMeta {
2262 id,
2263 started_at,
2264 ended_at,
2265 host,
2266 })
2267 }
2268
2269 fn bench_meta_strategy() -> impl Strategy<Value = BenchMeta> {
2271 (
2272 non_empty_string(),
2273 proptest::option::of(non_empty_string()),
2274 proptest::collection::vec(non_empty_string(), 1..5),
2275 1u32..100,
2276 0u32..10,
2277 proptest::option::of(1u64..10000),
2278 proptest::option::of(100u64..60000),
2279 )
2280 .prop_map(
2281 |(name, cwd, command, repeat, warmup, work_units, timeout_ms)| BenchMeta {
2282 name,
2283 cwd,
2284 command,
2285 repeat,
2286 warmup,
2287 work_units,
2288 timeout_ms,
2289 },
2290 )
2291 }
2292
2293 fn sample_strategy() -> impl Strategy<Value = Sample> {
2295 (
2296 0u64..100000,
2297 -128i32..128,
2298 any::<bool>(),
2299 any::<bool>(),
2300 proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..1000000), proptest::option::of(0u64..100000000), proptest::option::of("[a-zA-Z0-9 ]{0,50}"),
2306 proptest::option::of("[a-zA-Z0-9 ]{0,50}"),
2307 )
2308 .prop_map(
2309 |(
2310 wall_ms,
2311 exit_code,
2312 warmup,
2313 timed_out,
2314 cpu_ms,
2315 page_faults,
2316 ctx_switches,
2317 max_rss_kb,
2318 binary_bytes,
2319 stdout,
2320 stderr,
2321 )| Sample {
2322 wall_ms,
2323 exit_code,
2324 warmup,
2325 timed_out,
2326 cpu_ms,
2327 page_faults,
2328 ctx_switches,
2329 max_rss_kb,
2330 binary_bytes,
2331 stdout,
2332 stderr,
2333 },
2334 )
2335 }
2336
2337 fn u64_summary_strategy() -> impl Strategy<Value = U64Summary> {
2339 (0u64..1000000, 0u64..1000000, 0u64..1000000).prop_map(|(a, b, c)| {
2340 let mut vals = [a, b, c];
2341 vals.sort();
2342 U64Summary {
2343 min: vals[0],
2344 median: vals[1],
2345 max: vals[2],
2346 }
2347 })
2348 }
2349
2350 fn f64_summary_strategy() -> impl Strategy<Value = F64Summary> {
2352 (0.0f64..1000000.0, 0.0f64..1000000.0, 0.0f64..1000000.0).prop_map(|(a, b, c)| {
2353 let mut vals = [a, b, c];
2354 vals.sort_by(|x, y| x.partial_cmp(y).unwrap());
2355 F64Summary {
2356 min: vals[0],
2357 median: vals[1],
2358 max: vals[2],
2359 }
2360 })
2361 }
2362
2363 fn stats_strategy() -> impl Strategy<Value = Stats> {
2365 (
2366 u64_summary_strategy(),
2367 proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(u64_summary_strategy()), proptest::option::of(f64_summary_strategy()),
2373 )
2374 .prop_map(
2375 |(
2376 wall_ms,
2377 cpu_ms,
2378 page_faults,
2379 ctx_switches,
2380 max_rss_kb,
2381 binary_bytes,
2382 throughput_per_s,
2383 )| Stats {
2384 wall_ms,
2385 cpu_ms,
2386 page_faults,
2387 ctx_switches,
2388 max_rss_kb,
2389 binary_bytes,
2390 throughput_per_s,
2391 },
2392 )
2393 }
2394
2395 fn run_receipt_strategy() -> impl Strategy<Value = RunReceipt> {
2397 (
2398 tool_info_strategy(),
2399 run_meta_strategy(),
2400 bench_meta_strategy(),
2401 proptest::collection::vec(sample_strategy(), 1..10),
2402 stats_strategy(),
2403 )
2404 .prop_map(|(tool, run, bench, samples, stats)| RunReceipt {
2405 schema: RUN_SCHEMA_V1.to_string(),
2406 tool,
2407 run,
2408 bench,
2409 samples,
2410 stats,
2411 })
2412 }
2413
2414 proptest! {
2421 #![proptest_config(ProptestConfig::with_cases(100))]
2422
2423 #[test]
2424 fn run_receipt_serialization_round_trip(receipt in run_receipt_strategy()) {
2425 let json = serde_json::to_string(&receipt)
2427 .expect("RunReceipt should serialize to JSON");
2428
2429 let deserialized: RunReceipt = serde_json::from_str(&json)
2431 .expect("JSON should deserialize back to RunReceipt");
2432
2433 prop_assert_eq!(&receipt.schema, &deserialized.schema);
2435 prop_assert_eq!(&receipt.tool, &deserialized.tool);
2436 prop_assert_eq!(&receipt.run, &deserialized.run);
2437 prop_assert_eq!(&receipt.bench, &deserialized.bench);
2438 prop_assert_eq!(receipt.samples.len(), deserialized.samples.len());
2439
2440 for (orig, deser) in receipt.samples.iter().zip(deserialized.samples.iter()) {
2442 prop_assert_eq!(orig.wall_ms, deser.wall_ms);
2443 prop_assert_eq!(orig.exit_code, deser.exit_code);
2444 prop_assert_eq!(orig.warmup, deser.warmup);
2445 prop_assert_eq!(orig.timed_out, deser.timed_out);
2446 prop_assert_eq!(orig.cpu_ms, deser.cpu_ms);
2447 prop_assert_eq!(orig.page_faults, deser.page_faults);
2448 prop_assert_eq!(orig.ctx_switches, deser.ctx_switches);
2449 prop_assert_eq!(orig.max_rss_kb, deser.max_rss_kb);
2450 prop_assert_eq!(orig.binary_bytes, deser.binary_bytes);
2451 prop_assert_eq!(&orig.stdout, &deser.stdout);
2452 prop_assert_eq!(&orig.stderr, &deser.stderr);
2453 }
2454
2455 prop_assert_eq!(&receipt.stats.wall_ms, &deserialized.stats.wall_ms);
2457 prop_assert_eq!(&receipt.stats.cpu_ms, &deserialized.stats.cpu_ms);
2458 prop_assert_eq!(&receipt.stats.page_faults, &deserialized.stats.page_faults);
2459 prop_assert_eq!(&receipt.stats.ctx_switches, &deserialized.stats.ctx_switches);
2460 prop_assert_eq!(&receipt.stats.max_rss_kb, &deserialized.stats.max_rss_kb);
2461 prop_assert_eq!(&receipt.stats.binary_bytes, &deserialized.stats.binary_bytes);
2462
2463 match (&receipt.stats.throughput_per_s, &deserialized.stats.throughput_per_s) {
2466 (Some(orig), Some(deser)) => {
2467 let rel_tol = |a: f64, b: f64| {
2469 if a == 0.0 && b == 0.0 {
2470 true
2471 } else {
2472 let max_val = a.abs().max(b.abs());
2473 (a - b).abs() / max_val < 1e-10
2474 }
2475 };
2476 prop_assert!(rel_tol(orig.min, deser.min), "min mismatch: {} vs {}", orig.min, deser.min);
2477 prop_assert!(rel_tol(orig.median, deser.median), "median mismatch: {} vs {}", orig.median, deser.median);
2478 prop_assert!(rel_tol(orig.max, deser.max), "max mismatch: {} vs {}", orig.max, deser.max);
2479 }
2480 (None, None) => {}
2481 _ => prop_assert!(false, "throughput_per_s presence mismatch"),
2482 }
2483 }
2484 }
2485
2486 fn compare_ref_strategy() -> impl Strategy<Value = CompareRef> {
2490 (
2491 proptest::option::of(non_empty_string()),
2492 proptest::option::of(non_empty_string()),
2493 )
2494 .prop_map(|(path, run_id)| CompareRef { path, run_id })
2495 }
2496
2497 fn direction_strategy() -> impl Strategy<Value = Direction> {
2499 prop_oneof![Just(Direction::Lower), Just(Direction::Higher),]
2500 }
2501
2502 fn budget_strategy() -> impl Strategy<Value = Budget> {
2504 (0.01f64..1.0, 0.01f64..1.0, direction_strategy()).prop_map(
2505 |(threshold, warn_factor, direction)| {
2506 let warn_threshold = threshold * warn_factor;
2508 Budget {
2509 threshold,
2510 warn_threshold,
2511 direction,
2512 }
2513 },
2514 )
2515 }
2516
2517 fn metric_status_strategy() -> impl Strategy<Value = MetricStatus> {
2519 prop_oneof![
2520 Just(MetricStatus::Pass),
2521 Just(MetricStatus::Warn),
2522 Just(MetricStatus::Fail),
2523 ]
2524 }
2525
2526 fn delta_strategy() -> impl Strategy<Value = Delta> {
2528 (
2529 0.1f64..10000.0, 0.1f64..10000.0, metric_status_strategy(),
2532 )
2533 .prop_map(|(baseline, current, status)| {
2534 let ratio = current / baseline;
2535 let pct = (current - baseline) / baseline;
2536 let regression = if pct > 0.0 { pct } else { 0.0 };
2537 Delta {
2538 baseline,
2539 current,
2540 ratio,
2541 pct,
2542 regression,
2543 statistic: MetricStatistic::Median,
2544 significance: None,
2545 status,
2546 }
2547 })
2548 }
2549
2550 fn verdict_status_strategy() -> impl Strategy<Value = VerdictStatus> {
2552 prop_oneof![
2553 Just(VerdictStatus::Pass),
2554 Just(VerdictStatus::Warn),
2555 Just(VerdictStatus::Fail),
2556 ]
2557 }
2558
2559 fn verdict_counts_strategy() -> impl Strategy<Value = VerdictCounts> {
2561 (0u32..10, 0u32..10, 0u32..10).prop_map(|(pass, warn, fail)| VerdictCounts {
2562 pass,
2563 warn,
2564 fail,
2565 })
2566 }
2567
2568 fn verdict_strategy() -> impl Strategy<Value = Verdict> {
2570 (
2571 verdict_status_strategy(),
2572 verdict_counts_strategy(),
2573 proptest::collection::vec("[a-zA-Z0-9 ]{1,50}", 0..5),
2574 )
2575 .prop_map(|(status, counts, reasons)| Verdict {
2576 status,
2577 counts,
2578 reasons,
2579 })
2580 }
2581
2582 fn metric_strategy() -> impl Strategy<Value = Metric> {
2584 prop_oneof![
2585 Just(Metric::BinaryBytes),
2586 Just(Metric::CpuMs),
2587 Just(Metric::CtxSwitches),
2588 Just(Metric::MaxRssKb),
2589 Just(Metric::PageFaults),
2590 Just(Metric::ThroughputPerS),
2591 Just(Metric::WallMs),
2592 ]
2593 }
2594
2595 fn budgets_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Budget>> {
2597 proptest::collection::btree_map(metric_strategy(), budget_strategy(), 0..8)
2598 }
2599
2600 fn deltas_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Delta>> {
2602 proptest::collection::btree_map(metric_strategy(), delta_strategy(), 0..8)
2603 }
2604
2605 fn compare_receipt_strategy() -> impl Strategy<Value = CompareReceipt> {
2607 (
2608 tool_info_strategy(),
2609 bench_meta_strategy(),
2610 compare_ref_strategy(),
2611 compare_ref_strategy(),
2612 budgets_map_strategy(),
2613 deltas_map_strategy(),
2614 verdict_strategy(),
2615 )
2616 .prop_map(
2617 |(tool, bench, baseline_ref, current_ref, budgets, deltas, verdict)| {
2618 CompareReceipt {
2619 schema: COMPARE_SCHEMA_V1.to_string(),
2620 tool,
2621 bench,
2622 baseline_ref,
2623 current_ref,
2624 budgets,
2625 deltas,
2626 verdict,
2627 }
2628 },
2629 )
2630 }
2631
2632 fn f64_approx_eq(a: f64, b: f64) -> bool {
2634 if a == 0.0 && b == 0.0 {
2635 true
2636 } else {
2637 let max_val = a.abs().max(b.abs());
2638 if max_val == 0.0 {
2639 true
2640 } else {
2641 (a - b).abs() / max_val < 1e-10
2642 }
2643 }
2644 }
2645
2646 proptest! {
2653 #![proptest_config(ProptestConfig::with_cases(100))]
2654
2655 #[test]
2656 fn compare_receipt_serialization_round_trip(receipt in compare_receipt_strategy()) {
2657 let json = serde_json::to_string(&receipt)
2659 .expect("CompareReceipt should serialize to JSON");
2660
2661 let deserialized: CompareReceipt = serde_json::from_str(&json)
2663 .expect("JSON should deserialize back to CompareReceipt");
2664
2665 prop_assert_eq!(&receipt.schema, &deserialized.schema);
2667 prop_assert_eq!(&receipt.tool, &deserialized.tool);
2668 prop_assert_eq!(&receipt.bench, &deserialized.bench);
2669 prop_assert_eq!(&receipt.baseline_ref, &deserialized.baseline_ref);
2670 prop_assert_eq!(&receipt.current_ref, &deserialized.current_ref);
2671 prop_assert_eq!(&receipt.verdict, &deserialized.verdict);
2672
2673 prop_assert_eq!(receipt.budgets.len(), deserialized.budgets.len());
2675 for (metric, orig_budget) in &receipt.budgets {
2676 let deser_budget = deserialized.budgets.get(metric)
2677 .expect("Budget metric should exist in deserialized");
2678 prop_assert!(
2679 f64_approx_eq(orig_budget.threshold, deser_budget.threshold),
2680 "Budget threshold mismatch for {:?}: {} vs {}",
2681 metric, orig_budget.threshold, deser_budget.threshold
2682 );
2683 prop_assert!(
2684 f64_approx_eq(orig_budget.warn_threshold, deser_budget.warn_threshold),
2685 "Budget warn_threshold mismatch for {:?}: {} vs {}",
2686 metric, orig_budget.warn_threshold, deser_budget.warn_threshold
2687 );
2688 prop_assert_eq!(orig_budget.direction, deser_budget.direction);
2689 }
2690
2691 prop_assert_eq!(receipt.deltas.len(), deserialized.deltas.len());
2693 for (metric, orig_delta) in &receipt.deltas {
2694 let deser_delta = deserialized.deltas.get(metric)
2695 .expect("Delta metric should exist in deserialized");
2696 prop_assert!(
2697 f64_approx_eq(orig_delta.baseline, deser_delta.baseline),
2698 "Delta baseline mismatch for {:?}: {} vs {}",
2699 metric, orig_delta.baseline, deser_delta.baseline
2700 );
2701 prop_assert!(
2702 f64_approx_eq(orig_delta.current, deser_delta.current),
2703 "Delta current mismatch for {:?}: {} vs {}",
2704 metric, orig_delta.current, deser_delta.current
2705 );
2706 prop_assert!(
2707 f64_approx_eq(orig_delta.ratio, deser_delta.ratio),
2708 "Delta ratio mismatch for {:?}: {} vs {}",
2709 metric, orig_delta.ratio, deser_delta.ratio
2710 );
2711 prop_assert!(
2712 f64_approx_eq(orig_delta.pct, deser_delta.pct),
2713 "Delta pct mismatch for {:?}: {} vs {}",
2714 metric, orig_delta.pct, deser_delta.pct
2715 );
2716 prop_assert!(
2717 f64_approx_eq(orig_delta.regression, deser_delta.regression),
2718 "Delta regression mismatch for {:?}: {} vs {}",
2719 metric, orig_delta.regression, deser_delta.regression
2720 );
2721 prop_assert_eq!(orig_delta.status, deser_delta.status);
2722 }
2723 }
2724 }
2725
2726 fn budget_override_strategy() -> impl Strategy<Value = BudgetOverride> {
2730 (
2731 proptest::option::of(0.01f64..1.0),
2732 proptest::option::of(direction_strategy()),
2733 proptest::option::of(0.5f64..1.0),
2734 )
2735 .prop_map(|(threshold, direction, warn_factor)| BudgetOverride {
2736 threshold,
2737 direction,
2738 warn_factor,
2739 statistic: None,
2740 })
2741 }
2742
2743 fn budget_overrides_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, BudgetOverride>> {
2745 proptest::collection::btree_map(metric_strategy(), budget_override_strategy(), 0..4)
2746 }
2747
2748 fn bench_config_file_strategy() -> impl Strategy<Value = BenchConfigFile> {
2750 (
2751 non_empty_string(),
2752 proptest::option::of(non_empty_string()),
2753 proptest::option::of(1u64..10000),
2754 proptest::option::of("[0-9]+[smh]"), proptest::collection::vec(non_empty_string(), 1..5),
2756 proptest::option::of(1u32..100),
2757 proptest::option::of(0u32..10),
2758 proptest::option::of(proptest::collection::vec(metric_strategy(), 1..4)),
2759 proptest::option::of(budget_overrides_map_strategy()),
2760 )
2761 .prop_map(
2762 |(name, cwd, work, timeout, command, repeat, warmup, metrics, budgets)| {
2763 BenchConfigFile {
2764 name,
2765 cwd,
2766 work,
2767 timeout,
2768 command,
2769 repeat,
2770 warmup,
2771 metrics,
2772 budgets,
2773 }
2774 },
2775 )
2776 }
2777
2778 fn defaults_config_strategy() -> impl Strategy<Value = DefaultsConfig> {
2780 (
2781 proptest::option::of(1u32..100),
2782 proptest::option::of(0u32..10),
2783 proptest::option::of(0.01f64..1.0),
2784 proptest::option::of(0.5f64..1.0),
2785 proptest::option::of(non_empty_string()),
2786 proptest::option::of(non_empty_string()),
2787 proptest::option::of(non_empty_string()),
2788 proptest::option::of(non_empty_string()),
2789 )
2790 .prop_map(
2791 |(
2792 repeat,
2793 warmup,
2794 threshold,
2795 warn_factor,
2796 out_dir,
2797 baseline_dir,
2798 baseline_pattern,
2799 markdown_template,
2800 )| DefaultsConfig {
2801 repeat,
2802 warmup,
2803 threshold,
2804 warn_factor,
2805 out_dir,
2806 baseline_dir,
2807 baseline_pattern,
2808 markdown_template,
2809 },
2810 )
2811 }
2812
2813 fn baseline_server_config_strategy() -> impl Strategy<Value = BaselineServerConfig> {
2815 (
2816 proptest::option::of(non_empty_string()),
2817 proptest::option::of(non_empty_string()),
2818 proptest::option::of(non_empty_string()),
2819 proptest::bool::ANY,
2820 )
2821 .prop_map(
2822 |(url, api_key, project, fallback_to_local)| BaselineServerConfig {
2823 url,
2824 api_key,
2825 project,
2826 fallback_to_local,
2827 },
2828 )
2829 }
2830
2831 fn config_file_strategy() -> impl Strategy<Value = ConfigFile> {
2833 (
2834 defaults_config_strategy(),
2835 baseline_server_config_strategy(),
2836 proptest::collection::vec(bench_config_file_strategy(), 0..5),
2837 )
2838 .prop_map(|(defaults, baseline_server, benches)| ConfigFile {
2839 defaults,
2840 baseline_server,
2841 benches,
2842 })
2843 }
2844
2845 proptest! {
2852 #![proptest_config(ProptestConfig::with_cases(100))]
2853
2854 #[test]
2855 fn config_file_json_serialization_round_trip(config in config_file_strategy()) {
2856 let json = serde_json::to_string(&config)
2858 .expect("ConfigFile should serialize to JSON");
2859
2860 let deserialized: ConfigFile = serde_json::from_str(&json)
2862 .expect("JSON should deserialize back to ConfigFile");
2863
2864 prop_assert_eq!(config.defaults.repeat, deserialized.defaults.repeat);
2866 prop_assert_eq!(config.defaults.warmup, deserialized.defaults.warmup);
2867 prop_assert_eq!(&config.defaults.out_dir, &deserialized.defaults.out_dir);
2868 prop_assert_eq!(&config.defaults.baseline_dir, &deserialized.defaults.baseline_dir);
2869 prop_assert_eq!(
2870 &config.defaults.baseline_pattern,
2871 &deserialized.defaults.baseline_pattern
2872 );
2873 prop_assert_eq!(
2874 &config.defaults.markdown_template,
2875 &deserialized.defaults.markdown_template
2876 );
2877
2878 match (config.defaults.threshold, deserialized.defaults.threshold) {
2880 (Some(orig), Some(deser)) => {
2881 prop_assert!(
2882 f64_approx_eq(orig, deser),
2883 "defaults.threshold mismatch: {} vs {}",
2884 orig, deser
2885 );
2886 }
2887 (None, None) => {}
2888 _ => prop_assert!(false, "defaults.threshold presence mismatch"),
2889 }
2890
2891 match (config.defaults.warn_factor, deserialized.defaults.warn_factor) {
2892 (Some(orig), Some(deser)) => {
2893 prop_assert!(
2894 f64_approx_eq(orig, deser),
2895 "defaults.warn_factor mismatch: {} vs {}",
2896 orig, deser
2897 );
2898 }
2899 (None, None) => {}
2900 _ => prop_assert!(false, "defaults.warn_factor presence mismatch"),
2901 }
2902
2903 prop_assert_eq!(config.benches.len(), deserialized.benches.len());
2905 for (orig_bench, deser_bench) in config.benches.iter().zip(deserialized.benches.iter()) {
2906 prop_assert_eq!(&orig_bench.name, &deser_bench.name);
2907 prop_assert_eq!(&orig_bench.cwd, &deser_bench.cwd);
2908 prop_assert_eq!(orig_bench.work, deser_bench.work);
2909 prop_assert_eq!(&orig_bench.timeout, &deser_bench.timeout);
2910 prop_assert_eq!(&orig_bench.command, &deser_bench.command);
2911 prop_assert_eq!(&orig_bench.metrics, &deser_bench.metrics);
2912
2913 match (&orig_bench.budgets, &deser_bench.budgets) {
2915 (Some(orig_budgets), Some(deser_budgets)) => {
2916 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
2917 for (metric, orig_override) in orig_budgets {
2918 let deser_override = deser_budgets.get(metric)
2919 .expect("BudgetOverride metric should exist in deserialized");
2920
2921 match (orig_override.threshold, deser_override.threshold) {
2923 (Some(orig), Some(deser)) => {
2924 prop_assert!(
2925 f64_approx_eq(orig, deser),
2926 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
2927 metric, orig, deser
2928 );
2929 }
2930 (None, None) => {}
2931 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
2932 }
2933
2934 prop_assert_eq!(orig_override.direction, deser_override.direction);
2935
2936 match (orig_override.warn_factor, deser_override.warn_factor) {
2938 (Some(orig), Some(deser)) => {
2939 prop_assert!(
2940 f64_approx_eq(orig, deser),
2941 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
2942 metric, orig, deser
2943 );
2944 }
2945 (None, None) => {}
2946 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
2947 }
2948 }
2949 }
2950 (None, None) => {}
2951 _ => prop_assert!(false, "bench.budgets presence mismatch"),
2952 }
2953 }
2954 }
2955 }
2956
2957 proptest! {
2964 #![proptest_config(ProptestConfig::with_cases(100))]
2965
2966 #[test]
2967 fn config_file_toml_serialization_round_trip(config in config_file_strategy()) {
2968 let toml_str = toml::to_string(&config)
2970 .expect("ConfigFile should serialize to TOML");
2971
2972 let deserialized: ConfigFile = toml::from_str(&toml_str)
2974 .expect("TOML should deserialize back to ConfigFile");
2975
2976 prop_assert_eq!(config.defaults.repeat, deserialized.defaults.repeat);
2978 prop_assert_eq!(config.defaults.warmup, deserialized.defaults.warmup);
2979 prop_assert_eq!(&config.defaults.out_dir, &deserialized.defaults.out_dir);
2980 prop_assert_eq!(&config.defaults.baseline_dir, &deserialized.defaults.baseline_dir);
2981 prop_assert_eq!(
2982 &config.defaults.baseline_pattern,
2983 &deserialized.defaults.baseline_pattern
2984 );
2985 prop_assert_eq!(
2986 &config.defaults.markdown_template,
2987 &deserialized.defaults.markdown_template
2988 );
2989
2990 match (config.defaults.threshold, deserialized.defaults.threshold) {
2992 (Some(orig), Some(deser)) => {
2993 prop_assert!(
2994 f64_approx_eq(orig, deser),
2995 "defaults.threshold mismatch: {} vs {}",
2996 orig, deser
2997 );
2998 }
2999 (None, None) => {}
3000 _ => prop_assert!(false, "defaults.threshold presence mismatch"),
3001 }
3002
3003 match (config.defaults.warn_factor, deserialized.defaults.warn_factor) {
3004 (Some(orig), Some(deser)) => {
3005 prop_assert!(
3006 f64_approx_eq(orig, deser),
3007 "defaults.warn_factor mismatch: {} vs {}",
3008 orig, deser
3009 );
3010 }
3011 (None, None) => {}
3012 _ => prop_assert!(false, "defaults.warn_factor presence mismatch"),
3013 }
3014
3015 prop_assert_eq!(config.benches.len(), deserialized.benches.len());
3017 for (orig_bench, deser_bench) in config.benches.iter().zip(deserialized.benches.iter()) {
3018 prop_assert_eq!(&orig_bench.name, &deser_bench.name);
3019 prop_assert_eq!(&orig_bench.cwd, &deser_bench.cwd);
3020 prop_assert_eq!(orig_bench.work, deser_bench.work);
3021 prop_assert_eq!(&orig_bench.timeout, &deser_bench.timeout);
3022 prop_assert_eq!(&orig_bench.command, &deser_bench.command);
3023 prop_assert_eq!(&orig_bench.metrics, &deser_bench.metrics);
3024
3025 match (&orig_bench.budgets, &deser_bench.budgets) {
3027 (Some(orig_budgets), Some(deser_budgets)) => {
3028 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3029 for (metric, orig_override) in orig_budgets {
3030 let deser_override = deser_budgets.get(metric)
3031 .expect("BudgetOverride metric should exist in deserialized");
3032
3033 match (orig_override.threshold, deser_override.threshold) {
3035 (Some(orig), Some(deser)) => {
3036 prop_assert!(
3037 f64_approx_eq(orig, deser),
3038 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3039 metric, orig, deser
3040 );
3041 }
3042 (None, None) => {}
3043 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3044 }
3045
3046 prop_assert_eq!(orig_override.direction, deser_override.direction);
3047
3048 match (orig_override.warn_factor, deser_override.warn_factor) {
3050 (Some(orig), Some(deser)) => {
3051 prop_assert!(
3052 f64_approx_eq(orig, deser),
3053 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3054 metric, orig, deser
3055 );
3056 }
3057 (None, None) => {}
3058 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3059 }
3060 }
3061 }
3062 (None, None) => {}
3063 _ => prop_assert!(false, "bench.budgets presence mismatch"),
3064 }
3065 }
3066 }
3067 }
3068
3069 proptest! {
3077 #![proptest_config(ProptestConfig::with_cases(100))]
3078
3079 #[test]
3080 fn bench_config_file_json_serialization_round_trip(bench_config in bench_config_file_strategy()) {
3081 let json = serde_json::to_string(&bench_config)
3083 .expect("BenchConfigFile should serialize to JSON");
3084
3085 let deserialized: BenchConfigFile = serde_json::from_str(&json)
3087 .expect("JSON should deserialize back to BenchConfigFile");
3088
3089 prop_assert_eq!(&bench_config.name, &deserialized.name);
3091 prop_assert_eq!(&bench_config.command, &deserialized.command);
3092
3093 prop_assert_eq!(&bench_config.cwd, &deserialized.cwd);
3095 prop_assert_eq!(bench_config.work, deserialized.work);
3096 prop_assert_eq!(&bench_config.timeout, &deserialized.timeout);
3097 prop_assert_eq!(&bench_config.metrics, &deserialized.metrics);
3098
3099 match (&bench_config.budgets, &deserialized.budgets) {
3101 (Some(orig_budgets), Some(deser_budgets)) => {
3102 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3103 for (metric, orig_override) in orig_budgets {
3104 let deser_override = deser_budgets.get(metric)
3105 .expect("BudgetOverride metric should exist in deserialized");
3106
3107 match (orig_override.threshold, deser_override.threshold) {
3109 (Some(orig), Some(deser)) => {
3110 prop_assert!(
3111 f64_approx_eq(orig, deser),
3112 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3113 metric, orig, deser
3114 );
3115 }
3116 (None, None) => {}
3117 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3118 }
3119
3120 prop_assert_eq!(orig_override.direction, deser_override.direction);
3121
3122 match (orig_override.warn_factor, deser_override.warn_factor) {
3124 (Some(orig), Some(deser)) => {
3125 prop_assert!(
3126 f64_approx_eq(orig, deser),
3127 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3128 metric, orig, deser
3129 );
3130 }
3131 (None, None) => {}
3132 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3133 }
3134 }
3135 }
3136 (None, None) => {}
3137 _ => prop_assert!(false, "budgets presence mismatch"),
3138 }
3139 }
3140 }
3141
3142 proptest! {
3150 #![proptest_config(ProptestConfig::with_cases(100))]
3151
3152 #[test]
3153 fn bench_config_file_toml_serialization_round_trip(bench_config in bench_config_file_strategy()) {
3154 let toml_str = toml::to_string(&bench_config)
3156 .expect("BenchConfigFile should serialize to TOML");
3157
3158 let deserialized: BenchConfigFile = toml::from_str(&toml_str)
3160 .expect("TOML should deserialize back to BenchConfigFile");
3161
3162 prop_assert_eq!(&bench_config.name, &deserialized.name);
3164 prop_assert_eq!(&bench_config.command, &deserialized.command);
3165
3166 prop_assert_eq!(&bench_config.cwd, &deserialized.cwd);
3168 prop_assert_eq!(bench_config.work, deserialized.work);
3169 prop_assert_eq!(&bench_config.timeout, &deserialized.timeout);
3170 prop_assert_eq!(&bench_config.metrics, &deserialized.metrics);
3171
3172 match (&bench_config.budgets, &deserialized.budgets) {
3174 (Some(orig_budgets), Some(deser_budgets)) => {
3175 prop_assert_eq!(orig_budgets.len(), deser_budgets.len());
3176 for (metric, orig_override) in orig_budgets {
3177 let deser_override = deser_budgets.get(metric)
3178 .expect("BudgetOverride metric should exist in deserialized");
3179
3180 match (orig_override.threshold, deser_override.threshold) {
3182 (Some(orig), Some(deser)) => {
3183 prop_assert!(
3184 f64_approx_eq(orig, deser),
3185 "BudgetOverride threshold mismatch for {:?}: {} vs {}",
3186 metric, orig, deser
3187 );
3188 }
3189 (None, None) => {}
3190 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch for {:?}", metric),
3191 }
3192
3193 prop_assert_eq!(orig_override.direction, deser_override.direction);
3194
3195 match (orig_override.warn_factor, deser_override.warn_factor) {
3197 (Some(orig), Some(deser)) => {
3198 prop_assert!(
3199 f64_approx_eq(orig, deser),
3200 "BudgetOverride warn_factor mismatch for {:?}: {} vs {}",
3201 metric, orig, deser
3202 );
3203 }
3204 (None, None) => {}
3205 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch for {:?}", metric),
3206 }
3207 }
3208 }
3209 (None, None) => {}
3210 _ => prop_assert!(false, "budgets presence mismatch"),
3211 }
3212 }
3213 }
3214
3215 proptest! {
3222 #![proptest_config(ProptestConfig::with_cases(100))]
3223
3224 #[test]
3225 fn budget_json_serialization_round_trip(budget in budget_strategy()) {
3226 let json = serde_json::to_string(&budget)
3228 .expect("Budget should serialize to JSON");
3229
3230 let deserialized: Budget = serde_json::from_str(&json)
3232 .expect("JSON should deserialize back to Budget");
3233
3234 prop_assert!(
3236 f64_approx_eq(budget.threshold, deserialized.threshold),
3237 "Budget threshold mismatch: {} vs {}",
3238 budget.threshold, deserialized.threshold
3239 );
3240 prop_assert!(
3241 f64_approx_eq(budget.warn_threshold, deserialized.warn_threshold),
3242 "Budget warn_threshold mismatch: {} vs {}",
3243 budget.warn_threshold, deserialized.warn_threshold
3244 );
3245 prop_assert_eq!(budget.direction, deserialized.direction);
3246 }
3247 }
3248
3249 proptest! {
3256 #![proptest_config(ProptestConfig::with_cases(100))]
3257
3258 #[test]
3259 fn budget_override_json_serialization_round_trip(budget_override in budget_override_strategy()) {
3260 let json = serde_json::to_string(&budget_override)
3262 .expect("BudgetOverride should serialize to JSON");
3263
3264 let deserialized: BudgetOverride = serde_json::from_str(&json)
3266 .expect("JSON should deserialize back to BudgetOverride");
3267
3268 match (budget_override.threshold, deserialized.threshold) {
3270 (Some(orig), Some(deser)) => {
3271 prop_assert!(
3272 f64_approx_eq(orig, deser),
3273 "BudgetOverride threshold mismatch: {} vs {}",
3274 orig, deser
3275 );
3276 }
3277 (None, None) => {}
3278 _ => prop_assert!(false, "BudgetOverride threshold presence mismatch"),
3279 }
3280
3281 prop_assert_eq!(budget_override.direction, deserialized.direction);
3283
3284 match (budget_override.warn_factor, deserialized.warn_factor) {
3286 (Some(orig), Some(deser)) => {
3287 prop_assert!(
3288 f64_approx_eq(orig, deser),
3289 "BudgetOverride warn_factor mismatch: {} vs {}",
3290 orig, deser
3291 );
3292 }
3293 (None, None) => {}
3294 _ => prop_assert!(false, "BudgetOverride warn_factor presence mismatch"),
3295 }
3296 }
3297 }
3298
3299 proptest! {
3308 #![proptest_config(ProptestConfig::with_cases(100))]
3309
3310 #[test]
3311 fn budget_threshold_relationship_preserved(budget in budget_strategy()) {
3312 prop_assert!(
3314 budget.warn_threshold <= budget.threshold,
3315 "Budget invariant violated: warn_threshold ({}) should be <= threshold ({})",
3316 budget.warn_threshold, budget.threshold
3317 );
3318
3319 let json = serde_json::to_string(&budget)
3321 .expect("Budget should serialize to JSON");
3322 let deserialized: Budget = serde_json::from_str(&json)
3323 .expect("JSON should deserialize back to Budget");
3324
3325 prop_assert!(
3327 deserialized.warn_threshold <= deserialized.threshold,
3328 "Budget invariant violated after round-trip: warn_threshold ({}) should be <= threshold ({})",
3329 deserialized.warn_threshold, deserialized.threshold
3330 );
3331 }
3332 }
3333
3334 proptest! {
3337 #![proptest_config(ProptestConfig::with_cases(100))]
3338
3339 #[test]
3340 fn host_info_serialization_round_trip(info in host_info_strategy()) {
3341 let json = serde_json::to_string(&info).expect("HostInfo should serialize");
3342 let back: HostInfo = serde_json::from_str(&json).expect("should deserialize");
3343 prop_assert_eq!(info, back);
3344 }
3345
3346 #[test]
3347 fn sample_serialization_round_trip(sample in sample_strategy()) {
3348 let json = serde_json::to_string(&sample).expect("Sample should serialize");
3349 let back: Sample = serde_json::from_str(&json).expect("should deserialize");
3350 prop_assert_eq!(sample, back);
3351 }
3352
3353 #[test]
3354 fn u64_summary_serialization_round_trip(summary in u64_summary_strategy()) {
3355 let json = serde_json::to_string(&summary).expect("U64Summary should serialize");
3356 let back: U64Summary = serde_json::from_str(&json).expect("should deserialize");
3357 prop_assert_eq!(summary, back);
3358 }
3359
3360 #[test]
3361 fn f64_summary_serialization_round_trip(summary in f64_summary_strategy()) {
3362 let json = serde_json::to_string(&summary).expect("F64Summary should serialize");
3363 let back: F64Summary = serde_json::from_str(&json).expect("should deserialize");
3364 prop_assert!(f64_approx_eq(summary.min, back.min));
3365 prop_assert!(f64_approx_eq(summary.median, back.median));
3366 prop_assert!(f64_approx_eq(summary.max, back.max));
3367 }
3368
3369 #[test]
3370 fn stats_serialization_round_trip(stats in stats_strategy()) {
3371 let json = serde_json::to_string(&stats).expect("Stats should serialize");
3372 let back: Stats = serde_json::from_str(&json).expect("should deserialize");
3373 prop_assert_eq!(&stats.wall_ms, &back.wall_ms);
3374 prop_assert_eq!(&stats.cpu_ms, &back.cpu_ms);
3375 prop_assert_eq!(&stats.page_faults, &back.page_faults);
3376 prop_assert_eq!(&stats.ctx_switches, &back.ctx_switches);
3377 prop_assert_eq!(&stats.max_rss_kb, &back.max_rss_kb);
3378 prop_assert_eq!(&stats.binary_bytes, &back.binary_bytes);
3379 match (&stats.throughput_per_s, &back.throughput_per_s) {
3380 (Some(orig), Some(deser)) => {
3381 prop_assert!(f64_approx_eq(orig.min, deser.min));
3382 prop_assert!(f64_approx_eq(orig.median, deser.median));
3383 prop_assert!(f64_approx_eq(orig.max, deser.max));
3384 }
3385 (None, None) => {}
3386 _ => prop_assert!(false, "throughput_per_s presence mismatch"),
3387 }
3388 }
3389
3390 #[test]
3391 fn delta_serialization_round_trip(delta in delta_strategy()) {
3392 let json = serde_json::to_string(&delta).expect("Delta should serialize");
3393 let back: Delta = serde_json::from_str(&json).expect("should deserialize");
3394 prop_assert!(f64_approx_eq(delta.baseline, back.baseline));
3395 prop_assert!(f64_approx_eq(delta.current, back.current));
3396 prop_assert!(f64_approx_eq(delta.ratio, back.ratio));
3397 prop_assert!(f64_approx_eq(delta.pct, back.pct));
3398 prop_assert!(f64_approx_eq(delta.regression, back.regression));
3399 prop_assert_eq!(delta.statistic, back.statistic);
3400 prop_assert_eq!(delta.significance, back.significance);
3401 prop_assert_eq!(delta.status, back.status);
3402 }
3403
3404 #[test]
3405 fn verdict_serialization_round_trip(verdict in verdict_strategy()) {
3406 let json = serde_json::to_string(&verdict).expect("Verdict should serialize");
3407 let back: Verdict = serde_json::from_str(&json).expect("should deserialize");
3408 prop_assert_eq!(verdict, back);
3409 }
3410 }
3411
3412 fn severity_strategy() -> impl Strategy<Value = Severity> {
3415 prop_oneof![Just(Severity::Warn), Just(Severity::Fail),]
3416 }
3417
3418 fn finding_data_strategy() -> impl Strategy<Value = FindingData> {
3419 (
3420 non_empty_string(),
3421 0.1f64..10000.0,
3422 0.1f64..10000.0,
3423 0.0f64..100.0,
3424 0.01f64..1.0,
3425 direction_strategy(),
3426 )
3427 .prop_map(
3428 |(metric_name, baseline, current, regression_pct, threshold, direction)| {
3429 FindingData {
3430 metric_name,
3431 baseline,
3432 current,
3433 regression_pct,
3434 threshold,
3435 direction,
3436 }
3437 },
3438 )
3439 }
3440
3441 fn report_finding_strategy() -> impl Strategy<Value = ReportFinding> {
3442 (
3443 non_empty_string(),
3444 non_empty_string(),
3445 severity_strategy(),
3446 non_empty_string(),
3447 proptest::option::of(finding_data_strategy()),
3448 )
3449 .prop_map(|(check_id, code, severity, message, data)| ReportFinding {
3450 check_id,
3451 code,
3452 severity,
3453 message,
3454 data,
3455 })
3456 }
3457
3458 fn report_summary_strategy() -> impl Strategy<Value = ReportSummary> {
3459 (0u32..100, 0u32..100, 0u32..100).prop_map(|(pass_count, warn_count, fail_count)| {
3460 ReportSummary {
3461 pass_count,
3462 warn_count,
3463 fail_count,
3464 total_count: pass_count + warn_count + fail_count,
3465 }
3466 })
3467 }
3468
3469 fn perfgate_report_strategy() -> impl Strategy<Value = PerfgateReport> {
3470 (
3471 verdict_strategy(),
3472 proptest::option::of(compare_receipt_strategy()),
3473 proptest::collection::vec(report_finding_strategy(), 0..5),
3474 report_summary_strategy(),
3475 )
3476 .prop_map(|(verdict, compare, findings, summary)| PerfgateReport {
3477 report_type: REPORT_SCHEMA_V1.to_string(),
3478 verdict,
3479 compare,
3480 findings,
3481 summary,
3482 })
3483 }
3484
3485 proptest! {
3486 #![proptest_config(ProptestConfig::with_cases(50))]
3487
3488 #[test]
3489 fn perfgate_report_serialization_round_trip(report in perfgate_report_strategy()) {
3490 let json = serde_json::to_string(&report)
3491 .expect("PerfgateReport should serialize to JSON");
3492 let back: PerfgateReport = serde_json::from_str(&json)
3493 .expect("JSON should deserialize back to PerfgateReport");
3494
3495 prop_assert_eq!(&report.report_type, &back.report_type);
3496 prop_assert_eq!(&report.verdict, &back.verdict);
3497 prop_assert_eq!(&report.summary, &back.summary);
3498 prop_assert_eq!(report.findings.len(), back.findings.len());
3499 for (orig, deser) in report.findings.iter().zip(back.findings.iter()) {
3500 prop_assert_eq!(&orig.check_id, &deser.check_id);
3501 prop_assert_eq!(&orig.code, &deser.code);
3502 prop_assert_eq!(orig.severity, deser.severity);
3503 prop_assert_eq!(&orig.message, &deser.message);
3504 match (&orig.data, &deser.data) {
3505 (Some(o), Some(d)) => {
3506 prop_assert_eq!(&o.metric_name, &d.metric_name);
3507 prop_assert!(f64_approx_eq(o.baseline, d.baseline));
3508 prop_assert!(f64_approx_eq(o.current, d.current));
3509 prop_assert!(f64_approx_eq(o.regression_pct, d.regression_pct));
3510 prop_assert!(f64_approx_eq(o.threshold, d.threshold));
3511 prop_assert_eq!(o.direction, d.direction);
3512 }
3513 (None, None) => {}
3514 _ => prop_assert!(false, "finding data presence mismatch"),
3515 }
3516 }
3517 prop_assert_eq!(report.compare.is_some(), back.compare.is_some());
3520 }
3521 }
3522}
3523
3524#[cfg(test)]
3527mod golden_tests {
3528 use super::*;
3529
3530 const FIXTURE_PASS: &str = include_str!("../../../contracts/fixtures/sensor_report_pass.json");
3531 const FIXTURE_FAIL: &str = include_str!("../../../contracts/fixtures/sensor_report_fail.json");
3532 const FIXTURE_WARN: &str = include_str!("../../../contracts/fixtures/sensor_report_warn.json");
3533 const FIXTURE_NO_BASELINE: &str =
3534 include_str!("../../../contracts/fixtures/sensor_report_no_baseline.json");
3535 const FIXTURE_ERROR: &str =
3536 include_str!("../../../contracts/fixtures/sensor_report_error.json");
3537 const FIXTURE_MULTI_BENCH: &str =
3538 include_str!("../../../contracts/fixtures/sensor_report_multi_bench.json");
3539
3540 #[test]
3541 fn golden_sensor_report_pass() {
3542 let report: SensorReport =
3543 serde_json::from_str(FIXTURE_PASS).expect("fixture should parse");
3544 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3545 assert_eq!(report.tool.name, "perfgate");
3546 assert_eq!(report.verdict.status, SensorVerdictStatus::Pass);
3547 assert_eq!(report.verdict.counts.warn, 0);
3548 assert_eq!(report.verdict.counts.error, 0);
3549 assert!(report.findings.is_empty());
3550 assert_eq!(report.artifacts.len(), 4);
3551
3552 let json = serde_json::to_string(&report).unwrap();
3554 let back: SensorReport = serde_json::from_str(&json).unwrap();
3555 assert_eq!(report, back);
3556 }
3557
3558 #[test]
3559 fn golden_sensor_report_fail() {
3560 let report: SensorReport =
3561 serde_json::from_str(FIXTURE_FAIL).expect("fixture should parse");
3562 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3563 assert_eq!(report.verdict.status, SensorVerdictStatus::Fail);
3564 assert_eq!(report.verdict.counts.error, 1);
3565 assert_eq!(report.verdict.reasons, vec!["wall_ms_fail"]);
3566 assert_eq!(report.findings.len(), 1);
3567 assert_eq!(report.findings[0].check_id, CHECK_ID_BUDGET);
3568 assert_eq!(report.findings[0].code, FINDING_CODE_METRIC_FAIL);
3569 assert_eq!(report.findings[0].severity, SensorSeverity::Error);
3570
3571 let json = serde_json::to_string(&report).unwrap();
3572 let back: SensorReport = serde_json::from_str(&json).unwrap();
3573 assert_eq!(report, back);
3574 }
3575
3576 #[test]
3577 fn golden_sensor_report_warn() {
3578 let report: SensorReport =
3579 serde_json::from_str(FIXTURE_WARN).expect("fixture should parse");
3580 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3581 assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
3582 assert_eq!(report.verdict.counts.warn, 1);
3583 assert_eq!(report.verdict.reasons, vec!["wall_ms_warn"]);
3584 assert_eq!(report.findings.len(), 1);
3585 assert_eq!(report.findings[0].severity, SensorSeverity::Warn);
3586
3587 let json = serde_json::to_string(&report).unwrap();
3588 let back: SensorReport = serde_json::from_str(&json).unwrap();
3589 assert_eq!(report, back);
3590 }
3591
3592 #[test]
3593 fn golden_sensor_report_no_baseline() {
3594 let report: SensorReport =
3595 serde_json::from_str(FIXTURE_NO_BASELINE).expect("fixture should parse");
3596 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3597 assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
3598 assert_eq!(report.verdict.reasons, vec!["no_baseline"]);
3599 assert_eq!(
3600 report.run.capabilities.baseline.status,
3601 CapabilityStatus::Unavailable
3602 );
3603 assert_eq!(
3604 report.run.capabilities.baseline.reason.as_deref(),
3605 Some("no_baseline")
3606 );
3607 assert_eq!(report.findings.len(), 1);
3608 assert_eq!(report.findings[0].code, FINDING_CODE_BASELINE_MISSING);
3609
3610 let json = serde_json::to_string(&report).unwrap();
3611 let back: SensorReport = serde_json::from_str(&json).unwrap();
3612 assert_eq!(report, back);
3613 }
3614
3615 #[test]
3616 fn golden_sensor_report_error() {
3617 let report: SensorReport =
3618 serde_json::from_str(FIXTURE_ERROR).expect("fixture should parse");
3619 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3620 assert_eq!(report.verdict.status, SensorVerdictStatus::Fail);
3621 assert_eq!(report.verdict.reasons, vec!["tool_error"]);
3622 assert_eq!(report.findings.len(), 1);
3623 assert_eq!(report.findings[0].check_id, CHECK_ID_TOOL_RUNTIME);
3624 assert_eq!(report.findings[0].code, FINDING_CODE_RUNTIME_ERROR);
3625 assert!(report.artifacts.is_empty());
3626
3627 let json = serde_json::to_string(&report).unwrap();
3628 let back: SensorReport = serde_json::from_str(&json).unwrap();
3629 assert_eq!(report, back);
3630 }
3631
3632 #[test]
3633 fn golden_sensor_report_multi_bench() {
3634 let report: SensorReport =
3635 serde_json::from_str(FIXTURE_MULTI_BENCH).expect("fixture should parse");
3636 assert_eq!(report.schema, SENSOR_REPORT_SCHEMA_V1);
3637 assert_eq!(report.verdict.status, SensorVerdictStatus::Warn);
3638 assert_eq!(report.verdict.counts.warn, 2);
3639 assert_eq!(report.findings.len(), 2);
3640 for finding in &report.findings {
3642 assert_eq!(finding.code, FINDING_CODE_BASELINE_MISSING);
3643 }
3644 assert_eq!(report.artifacts.len(), 5);
3645
3646 let json = serde_json::to_string(&report).unwrap();
3647 let back: SensorReport = serde_json::from_str(&json).unwrap();
3648 assert_eq!(report, back);
3649 }
3650}