Skip to main content

perfgate_types/
lib.rs

1//! Shared types for perfgate.
2//!
3//! Design goal: versioned, explicit, boring.
4//! These structs are used for receipts, PR comments, and (eventually) long-term baselines.
5//!
6//! # Examples
7//!
8//! Round-trip a [`ToolInfo`] through JSON:
9//!
10//! ```
11//! use perfgate_types::ToolInfo;
12//!
13//! let tool = ToolInfo { name: "perfgate".into(), version: "1.0.0".into() };
14//! let json = serde_json::to_string(&tool).unwrap();
15//! let back: ToolInfo = serde_json::from_str(&json).unwrap();
16//! assert_eq!(tool, back);
17//! ```
18//!
19//! # Feature Flags
20//!
21//! - `arbitrary`: Enables `Arbitrary` derive for structure-aware fuzzing with cargo-fuzz.
22
23mod 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
47// Stable contract identifiers and tokens.
48pub 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
62// Error classification stages.
63pub 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
68// Error kind constants.
69pub const ERROR_KIND_IO: &str = "io_error";
70pub const ERROR_KIND_PARSE: &str = "parse_error";
71pub const ERROR_KIND_EXEC: &str = "exec_error";
72
73// Baseline reason tokens.
74pub const BASELINE_REASON_NO_BASELINE: &str = "no_baseline";
75
76// Truncation signaling constants.
77pub const CHECK_ID_TOOL_TRUNCATION: &str = "tool.truncation";
78pub const FINDING_CODE_TRUNCATED: &str = "truncated";
79pub const MAX_FINDINGS_DEFAULT: usize = 100;
80
81// Sensor report schema for cockpit integration
82pub const SENSOR_REPORT_SCHEMA_V1: &str = "sensor.report.v1";
83
84// ----------------------------
85// Sensor report types (sensor.report.v1)
86// ----------------------------
87
88/// Capability status for "No Green By Omission" principle.
89#[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/// A capability with its status and optional reason.
99#[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/// Capabilities available to the sensor.
108#[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/// Run metadata for the sensor report.
117#[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/// Verdict status for the sensor report.
129#[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/// Verdict counts for the sensor report.
140#[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/// Verdict for the sensor report.
149#[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/// Severity level for sensor findings (cockpit vocabulary).
158#[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/// A finding from the sensor.
168#[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/// An artifact produced by the sensor.
181#[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/// The sensor.report.v1 envelope for cockpit integration.
190///
191/// This wraps PerfgateReport in a cockpit-compatible format with:
192/// - Run metadata including capabilities
193/// - Verdict using cockpit vocabulary (error instead of fail)
194/// - Artifacts list
195/// - Native perfgate data
196#[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    /// Operating system (e.g., "linux", "macos", "windows")
219    pub os: String,
220
221    /// CPU architecture (e.g., "x86_64", "aarch64")
222    pub arch: String,
223
224    /// Number of logical CPUs (best-effort, None if unavailable)
225    #[serde(skip_serializing_if = "Option::is_none", default)]
226    pub cpu_count: Option<u32>,
227
228    /// Total system memory in bytes (best-effort, None if unavailable)
229    #[serde(skip_serializing_if = "Option::is_none", default)]
230    pub memory_bytes: Option<u64>,
231
232    /// Hashed hostname for fingerprinting (opt-in, privacy-preserving).
233    /// When present, this is a SHA-256 hash of the actual hostname.
234    #[serde(skip_serializing_if = "Option::is_none", default)]
235    pub hostname_hash: Option<String>,
236}
237
238/// Policy for handling host mismatches when comparing receipts from different machines.
239///
240/// Host mismatches are detected when:
241/// - Different `os` or `arch`
242/// - Significant difference in `cpu_count` (> 2x)
243/// - Significant difference in `memory_bytes` (> 2x)
244/// - Different `hostname_hash` (if both present)
245#[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    /// Warn about host mismatch but continue with comparison (default).
250    #[default]
251    Warn,
252    /// Treat host mismatch as an error (exit 1).
253    Error,
254    /// Ignore host mismatches completely (suppress warnings).
255    Ignore,
256}
257
258impl HostMismatchPolicy {
259    /// Returns the string representation of this policy.
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use perfgate_types::HostMismatchPolicy;
265    ///
266    /// assert_eq!(HostMismatchPolicy::Warn.as_str(), "warn");
267    /// assert_eq!(HostMismatchPolicy::default().as_str(), "warn");
268    /// ```
269    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/// Details about a detected host mismatch between baseline and current runs.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct HostMismatchInfo {
281    /// Human-readable description of the mismatch.
282    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    /// Optional working directory (stringified path).
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub cwd: Option<String>,
302
303    /// argv vector (no shell parsing).
304    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    /// CPU time (user + system) in milliseconds (Unix only).
329    #[serde(skip_serializing_if = "Option::is_none", default)]
330    pub cpu_ms: Option<u64>,
331
332    /// Major page faults (Unix only).
333    #[serde(skip_serializing_if = "Option::is_none", default)]
334    pub page_faults: Option<u64>,
335
336    /// Voluntary + involuntary context switches (Unix only).
337    #[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    /// Size of executed binary in bytes (best-effort).
344    #[serde(skip_serializing_if = "Option::is_none", default)]
345    pub binary_bytes: Option<u64>,
346
347    /// Truncated stdout (bytes interpreted as UTF-8 lossily).
348    #[serde(skip_serializing_if = "Option::is_none", default)]
349    pub stdout: Option<String>,
350
351    /// Truncated stderr (bytes interpreted as UTF-8 lossily).
352    #[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/// Aggregated statistics for a benchmark run.
373///
374/// # Examples
375///
376/// ```
377/// use perfgate_types::{Stats, U64Summary};
378///
379/// let stats = Stats {
380///     wall_ms: U64Summary { median: 100, min: 90, max: 120 },
381///     cpu_ms: None,
382///     page_faults: None,
383///     ctx_switches: None,
384///     max_rss_kb: Some(U64Summary { median: 4096, min: 4000, max: 4200 }),
385///     binary_bytes: None,
386///     throughput_per_s: None,
387/// };
388/// assert_eq!(stats.wall_ms.median, 100);
389/// assert_eq!(stats.max_rss_kb.unwrap().median, 4096);
390/// ```
391#[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    /// CPU time (user + system) summary in milliseconds (Unix only).
397    #[serde(skip_serializing_if = "Option::is_none", default)]
398    pub cpu_ms: Option<U64Summary>,
399
400    /// Major page faults summary (Unix only).
401    #[serde(skip_serializing_if = "Option::is_none", default)]
402    pub page_faults: Option<U64Summary>,
403
404    /// Voluntary + involuntary context switches summary (Unix only).
405    #[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    /// Size of executed binary in bytes (best-effort).
412    #[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/// A versioned receipt from a single benchmark run (`perfgate.run.v1`).
420///
421/// # Examples
422///
423/// ```
424/// use perfgate_types::*;
425///
426/// let receipt = RunReceipt {
427///     schema: RUN_SCHEMA_V1.to_string(),
428///     tool: ToolInfo { name: "perfgate".into(), version: "0.1.0".into() },
429///     run: RunMeta {
430///         id: "run-1".into(),
431///         started_at: "2024-01-01T00:00:00Z".into(),
432///         ended_at: "2024-01-01T00:00:01Z".into(),
433///         host: HostInfo {
434///             os: "linux".into(), arch: "x86_64".into(),
435///             cpu_count: None, memory_bytes: None, hostname_hash: None,
436///         },
437///     },
438///     bench: BenchMeta {
439///         name: "my-bench".into(), cwd: None,
440///         command: vec!["echo".into(), "hello".into()],
441///         repeat: 3, warmup: 0, work_units: None, timeout_ms: None,
442///     },
443///     samples: vec![],
444///     stats: Stats {
445///         wall_ms: U64Summary { median: 100, min: 90, max: 120 },
446///         cpu_ms: None, page_faults: None, ctx_switches: None,
447///         max_rss_kb: None, binary_bytes: None, throughput_per_s: None,
448///     },
449/// };
450///
451/// // Serialize to JSON
452/// let json = serde_json::to_string(&receipt).unwrap();
453/// assert!(json.contains("perfgate.run.v1"));
454/// ```
455#[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    /// Returns the snake_case string key for this metric.
483    ///
484    /// # Examples
485    ///
486    /// ```
487    /// use perfgate_types::Metric;
488    ///
489    /// assert_eq!(Metric::WallMs.as_str(), "wall_ms");
490    /// assert_eq!(Metric::ThroughputPerS.as_str(), "throughput_per_s");
491    /// ```
492    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    /// Parses a snake_case string key into a [`Metric`], returning `None` for unknown keys.
505    ///
506    /// # Examples
507    ///
508    /// ```
509    /// use perfgate_types::Metric;
510    ///
511    /// assert_eq!(Metric::parse_key("wall_ms"), Some(Metric::WallMs));
512    /// assert_eq!(Metric::parse_key("max_rss_kb"), Some(Metric::MaxRssKb));
513    /// assert_eq!(Metric::parse_key("unknown"), None);
514    /// ```
515    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    /// Returns the default comparison direction for this metric.
529    ///
530    /// Most metrics use [`Direction::Lower`] (lower is better), except
531    /// throughput which uses [`Direction::Higher`].
532    ///
533    /// # Examples
534    ///
535    /// ```
536    /// use perfgate_types::{Metric, Direction};
537    ///
538    /// assert_eq!(Metric::WallMs.default_direction(), Direction::Lower);
539    /// assert_eq!(Metric::ThroughputPerS.default_direction(), Direction::Higher);
540    /// ```
541    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        // Near-budget warnings are useful in PRs, but they should not fail by default.
555        0.9
556    }
557
558    /// Returns the human-readable display unit for this metric.
559    ///
560    /// # Examples
561    ///
562    /// ```
563    /// use perfgate_types::Metric;
564    ///
565    /// assert_eq!(Metric::WallMs.display_unit(), "ms");
566    /// assert_eq!(Metric::MaxRssKb.display_unit(), "KB");
567    /// assert_eq!(Metric::ThroughputPerS.display_unit(), "/s");
568    /// ```
569    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    /// Returns the string representation of this statistic.
593    ///
594    /// # Examples
595    ///
596    /// ```
597    /// use perfgate_types::MetricStatistic;
598    ///
599    /// assert_eq!(MetricStatistic::Median.as_str(), "median");
600    /// assert_eq!(MetricStatistic::P95.as_str(), "p95");
601    /// ```
602    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    /// Fail threshold, as a fraction (0.20 = 20% regression allowed).
626    pub threshold: f64,
627
628    /// Warn threshold, as a fraction.
629    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    /// Returns the string representation of this status.
663    ///
664    /// # Examples
665    ///
666    /// ```
667    /// use perfgate_types::MetricStatus;
668    ///
669    /// assert_eq!(MetricStatus::Pass.as_str(), "pass");
670    /// assert_eq!(MetricStatus::Warn.as_str(), "warn");
671    /// assert_eq!(MetricStatus::Fail.as_str(), "fail");
672    /// ```
673    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    /// current / baseline
689    pub ratio: f64,
690
691    /// (current - baseline) / baseline
692    pub pct: f64,
693
694    /// Positive regression amount, normalized as a fraction.
695    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/// Overall verdict for a comparison, with pass/warn/fail counts.
734///
735/// # Examples
736///
737/// ```
738/// use perfgate_types::{Verdict, VerdictStatus, VerdictCounts};
739///
740/// let verdict = Verdict {
741///     status: VerdictStatus::Pass,
742///     counts: VerdictCounts { pass: 2, warn: 0, fail: 0 },
743///     reasons: vec![],
744/// };
745/// assert_eq!(verdict.status, VerdictStatus::Pass);
746///
747/// let failing = Verdict {
748///     status: VerdictStatus::Fail,
749///     counts: VerdictCounts { pass: 1, warn: 0, fail: 1 },
750///     reasons: vec!["wall_ms.fail".into()],
751/// };
752/// assert_eq!(failing.status, VerdictStatus::Fail);
753/// assert!(!failing.reasons.is_empty());
754/// ```
755#[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/// A versioned receipt comparing baseline vs current (`perfgate.compare.v1`).
764///
765/// # Examples
766///
767/// ```
768/// use perfgate_types::*;
769/// use std::collections::BTreeMap;
770///
771/// let receipt = CompareReceipt {
772///     schema: COMPARE_SCHEMA_V1.to_string(),
773///     tool: ToolInfo { name: "perfgate".into(), version: "0.1.0".into() },
774///     bench: BenchMeta {
775///         name: "my-bench".into(), cwd: None,
776///         command: vec!["echo".into()], repeat: 5, warmup: 0,
777///         work_units: None, timeout_ms: None,
778///     },
779///     baseline_ref: CompareRef { path: Some("base.json".into()), run_id: None },
780///     current_ref: CompareRef { path: Some("cur.json".into()), run_id: None },
781///     budgets: BTreeMap::new(),
782///     deltas: BTreeMap::new(),
783///     verdict: Verdict {
784///         status: VerdictStatus::Pass,
785///         counts: VerdictCounts { pass: 0, warn: 0, fail: 0 },
786///         reasons: vec![],
787///     },
788/// };
789/// assert_eq!(receipt.schema, "perfgate.compare.v1");
790/// ```
791#[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// ----------------------------
809// Report types (perfgate.report.v1)
810// ----------------------------
811
812/// Severity level for a finding.
813#[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/// Data associated with a metric finding.
822#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
823#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
824pub struct FindingData {
825    /// Name of the metric (e.g., "wall_ms", "max_rss_kb").
826    #[serde(rename = "metric_name")]
827    pub metric_name: String,
828
829    /// Baseline value.
830    pub baseline: f64,
831
832    /// Current value.
833    pub current: f64,
834
835    /// Regression percentage (positive means regression).
836    #[serde(rename = "regression_pct")]
837    pub regression_pct: f64,
838
839    /// Threshold that was exceeded (as a fraction, e.g., 0.20 for 20%).
840    pub threshold: f64,
841
842    /// Whether lower is better or higher is better.
843    pub direction: Direction,
844}
845
846/// A single finding from the performance check.
847#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
848#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
849pub struct ReportFinding {
850    /// Unique identifier for the check type (e.g., "perf.budget", "perf.baseline").
851    #[serde(rename = "check_id")]
852    pub check_id: String,
853
854    /// Machine-readable code for the finding (e.g., "metric_warn", "metric_fail", "missing").
855    pub code: String,
856
857    /// Severity level (warn or fail).
858    pub severity: Severity,
859
860    /// Human-readable message describing the finding.
861    pub message: String,
862
863    /// Structured data about the finding (present for metric findings, absent for structural findings).
864    #[serde(skip_serializing_if = "Option::is_none")]
865    pub data: Option<FindingData>,
866}
867
868/// Summary counts and key metrics for the report.
869#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
870#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
871pub struct ReportSummary {
872    /// Number of metrics that passed.
873    #[serde(rename = "pass_count")]
874    pub pass_count: u32,
875
876    /// Number of metrics that warned.
877    #[serde(rename = "warn_count")]
878    pub warn_count: u32,
879
880    /// Number of metrics that failed.
881    #[serde(rename = "fail_count")]
882    pub fail_count: u32,
883
884    /// Total number of metrics checked.
885    #[serde(rename = "total_count")]
886    pub total_count: u32,
887}
888
889/// A performance report wrapping compare results in a cockpit-compatible envelope.
890#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
891#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
892pub struct PerfgateReport {
893    /// Schema identifier, always "perfgate.report.v1".
894    #[serde(rename = "report_type")]
895    pub report_type: String,
896
897    /// Overall verdict for the report.
898    pub verdict: Verdict,
899
900    /// The full compare receipt (absent when baseline is missing).
901    #[serde(skip_serializing_if = "Option::is_none")]
902    pub compare: Option<CompareReceipt>,
903
904    /// List of findings (warnings and failures).
905    pub findings: Vec<ReportFinding>,
906
907    /// Summary counts.
908    pub summary: ReportSummary,
909}
910
911// ----------------------------
912// Optional config file schema
913// ----------------------------
914
915/// Top-level configuration file (`perfgate.toml`).
916///
917/// # Examples
918///
919/// ```
920/// use perfgate_types::ConfigFile;
921///
922/// let toml_str = r#"
923/// [defaults]
924/// repeat = 5
925/// threshold = 0.20
926///
927/// [[bench]]
928/// name = "my-bench"
929/// command = ["echo", "hello"]
930/// "#;
931///
932/// let config: ConfigFile = toml::from_str(toml_str).unwrap();
933/// assert_eq!(config.benches.len(), 1);
934/// assert_eq!(config.benches[0].name, "my-bench");
935/// assert_eq!(config.defaults.repeat, Some(5));
936/// ```
937#[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    /// Optional baseline server configuration for centralized baseline management.
944    #[serde(default)]
945    pub baseline_server: BaselineServerConfig,
946
947    #[serde(default, rename = "bench")]
948    pub benches: Vec<BenchConfigFile>,
949}
950
951impl ConfigFile {
952    /// Validate all bench names in this config. Returns an error if any name is invalid.
953    ///
954    /// # Examples
955    ///
956    /// ```
957    /// use perfgate_types::ConfigFile;
958    ///
959    /// let good: ConfigFile = toml::from_str(r#"
960    /// [[bench]]
961    /// name = "valid-bench"
962    /// command = ["echo"]
963    /// "#).unwrap();
964    /// assert!(good.validate().is_ok());
965    ///
966    /// let bad: ConfigFile = toml::from_str(r#"
967    /// [[bench]]
968    /// name = "INVALID|name"
969    /// command = ["echo"]
970    /// "#).unwrap();
971    /// assert!(bad.validate().is_err());
972    /// ```
973    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    /// Optional baseline discovery pattern. Supports `{bench}` placeholder.
1003    /// Example: `baselines/{bench}.json`.
1004    #[serde(skip_serializing_if = "Option::is_none", default)]
1005    pub baseline_pattern: Option<String>,
1006
1007    /// Optional Handlebars template path for markdown comments.
1008    #[serde(skip_serializing_if = "Option::is_none", default)]
1009    pub markdown_template: Option<String>,
1010}
1011
1012/// Configuration for the baseline server connection.
1013///
1014/// When configured, the CLI can use a centralized baseline server
1015/// for storing and retrieving baselines instead of local files.
1016///
1017/// # Examples
1018///
1019/// ```toml
1020/// [baseline_server]
1021/// url = "http://localhost:3000/api/v1"
1022/// api_key = "pg_live_xxx"
1023/// project = "my-project"
1024/// fallback_to_local = true
1025/// ```
1026#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1027#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
1028pub struct BaselineServerConfig {
1029    /// URL of the baseline server (e.g., "http://localhost:3000/api/v1").
1030    #[serde(skip_serializing_if = "Option::is_none", default)]
1031    pub url: Option<String>,
1032
1033    /// API key for authentication.
1034    #[serde(skip_serializing_if = "Option::is_none", default)]
1035    pub api_key: Option<String>,
1036
1037    /// Project name for multi-tenancy.
1038    #[serde(skip_serializing_if = "Option::is_none", default)]
1039    pub project: Option<String>,
1040
1041    /// Fall back to local storage when server is unavailable.
1042    #[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    /// Returns true if server is configured (has a URL).
1052    pub fn is_configured(&self) -> bool {
1053        self.url.is_some() && !self.url.as_ref().unwrap().is_empty()
1054    }
1055
1056    /// Resolves the server URL from config or environment variable.
1057    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    /// Resolves the API key from config or environment variable.
1064    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    /// Resolves the project name from config or environment variable.
1071    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    /// Duration string parseable by humantime, e.g. "2s".
1090    #[serde(skip_serializing_if = "Option::is_none")]
1091    pub timeout: Option<String>,
1092
1093    /// argv vector (no shell parsing).
1094    pub command: Vec<String>,
1095
1096    /// Number of measured samples (overrides defaults.repeat).
1097    #[serde(skip_serializing_if = "Option::is_none")]
1098    pub repeat: Option<u32>,
1099
1100    /// Warmup samples excluded from stats (overrides defaults.warmup).
1101    #[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    /// Warn fraction (warn_threshold = threshold * warn_factor).
1121    #[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 backward compatibility: receipts without new host fields still parse
1202    #[test]
1203    fn backward_compat_host_info_without_new_fields() {
1204        // Old format with only os and arch
1205        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 that new fields are serialized when present
1234    #[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 that new fields are omitted when None (skip_serializing_if)
1251    #[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 round-trip serialization with all fields
1268    #[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        // Exactly 64 chars -> ok
1321        let name_64 = "a".repeat(BENCH_NAME_MAX_LEN);
1322        assert!(validate_bench_name(&name_64).is_ok());
1323
1324        // 65 chars -> rejected
1325        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    // ---- Serde round-trip unit tests ----
1435
1436    #[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        // Old format: host has only os/arch, no cpu_count/memory_bytes/hostname_hash
1946        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); // default
2019        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        // Verify optional fields are omitted from JSON
2116        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 backward compatibility: full RunReceipt without new host fields parses
2167    #[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    // Strategy for generating valid non-empty strings (for names, IDs, etc.)
2205    fn non_empty_string() -> impl Strategy<Value = String> {
2206        "[a-zA-Z0-9_-]{1,20}".prop_map(|s| s)
2207    }
2208
2209    // Strategy for generating valid RFC3339 timestamps
2210    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    // Strategy for ToolInfo
2228    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    // Strategy for HostInfo
2234    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), // Up to 64GB
2240            proptest::option::of("[a-f0-9]{64}"),    // SHA-256 hex hash
2241        )
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    // Strategy for RunMeta
2254    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    // Strategy for BenchMeta
2270    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    // Strategy for Sample
2294    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),   // cpu_ms
2301            proptest::option::of(0u64..1000000),   // page_faults
2302            proptest::option::of(0u64..1000000),   // ctx_switches
2303            proptest::option::of(0u64..1000000),   // max_rss_kb
2304            proptest::option::of(0u64..100000000), // binary_bytes
2305            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    // Strategy for U64Summary
2338    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    // Strategy for F64Summary - using finite positive floats
2351    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    // Strategy for Stats
2364    fn stats_strategy() -> impl Strategy<Value = Stats> {
2365        (
2366            u64_summary_strategy(),
2367            proptest::option::of(u64_summary_strategy()), // cpu_ms
2368            proptest::option::of(u64_summary_strategy()), // page_faults
2369            proptest::option::of(u64_summary_strategy()), // ctx_switches
2370            proptest::option::of(u64_summary_strategy()), // max_rss_kb
2371            proptest::option::of(u64_summary_strategy()), // binary_bytes
2372            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    // Strategy for RunReceipt
2396    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    // **Property 8: Serialization Round-Trip (RunReceipt)**
2415    //
2416    // For any valid RunReceipt, serializing to JSON then deserializing
2417    // SHALL produce an equivalent value.
2418    //
2419    // **Validates: Requirements 10.1**
2420    proptest! {
2421        #![proptest_config(ProptestConfig::with_cases(100))]
2422
2423        #[test]
2424        fn run_receipt_serialization_round_trip(receipt in run_receipt_strategy()) {
2425            // Serialize to JSON
2426            let json = serde_json::to_string(&receipt)
2427                .expect("RunReceipt should serialize to JSON");
2428
2429            // Deserialize back
2430            let deserialized: RunReceipt = serde_json::from_str(&json)
2431                .expect("JSON should deserialize back to RunReceipt");
2432
2433            // Compare - for f64 fields we need to handle floating point comparison
2434            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            // Compare samples
2441            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            // Compare stats
2456            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            // For f64 throughput, compare with tolerance for floating point
2464            // JSON serialization may lose some precision for large floats
2465            match (&receipt.stats.throughput_per_s, &deserialized.stats.throughput_per_s) {
2466                (Some(orig), Some(deser)) => {
2467                    // Use relative tolerance for floating point comparison
2468                    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    // --- Strategies for CompareReceipt ---
2487
2488    // Strategy for CompareRef
2489    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    // Strategy for Direction
2498    fn direction_strategy() -> impl Strategy<Value = Direction> {
2499        prop_oneof![Just(Direction::Lower), Just(Direction::Higher),]
2500    }
2501
2502    // Strategy for Budget - using finite positive floats for thresholds
2503    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                // warn_threshold should be <= threshold
2507                let warn_threshold = threshold * warn_factor;
2508                Budget {
2509                    threshold,
2510                    warn_threshold,
2511                    direction,
2512                }
2513            },
2514        )
2515    }
2516
2517    // Strategy for MetricStatus
2518    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    // Strategy for Delta - using finite positive floats
2527    fn delta_strategy() -> impl Strategy<Value = Delta> {
2528        (
2529            0.1f64..10000.0, // baseline (positive, non-zero)
2530            0.1f64..10000.0, // current (positive, non-zero)
2531            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    // Strategy for VerdictStatus
2551    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    // Strategy for VerdictCounts
2560    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    // Strategy for Verdict
2569    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    // Strategy for Metric
2583    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    // Strategy for BTreeMap<Metric, Budget>
2596    fn budgets_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Budget>> {
2597        proptest::collection::btree_map(metric_strategy(), budget_strategy(), 0..8)
2598    }
2599
2600    // Strategy for BTreeMap<Metric, Delta>
2601    fn deltas_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Delta>> {
2602        proptest::collection::btree_map(metric_strategy(), delta_strategy(), 0..8)
2603    }
2604
2605    // Strategy for CompareReceipt
2606    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    // Helper function for comparing f64 values with tolerance
2633    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    // **Property 8: Serialization Round-Trip (CompareReceipt)**
2647    //
2648    // For any valid CompareReceipt, serializing to JSON then deserializing
2649    // SHALL produce an equivalent value.
2650    //
2651    // **Validates: Requirements 10.2**
2652    proptest! {
2653        #![proptest_config(ProptestConfig::with_cases(100))]
2654
2655        #[test]
2656        fn compare_receipt_serialization_round_trip(receipt in compare_receipt_strategy()) {
2657            // Serialize to JSON
2658            let json = serde_json::to_string(&receipt)
2659                .expect("CompareReceipt should serialize to JSON");
2660
2661            // Deserialize back
2662            let deserialized: CompareReceipt = serde_json::from_str(&json)
2663                .expect("JSON should deserialize back to CompareReceipt");
2664
2665            // Compare non-f64 fields directly
2666            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            // Compare budgets map - contains f64 fields
2674            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            // Compare deltas map - contains f64 fields
2692            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    // --- Strategies for ConfigFile ---
2727
2728    // Strategy for BudgetOverride
2729    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    // Strategy for BTreeMap<Metric, BudgetOverride>
2744    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    // Strategy for BenchConfigFile
2749    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]"), // humantime-like duration strings
2755            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    // Strategy for DefaultsConfig
2779    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    // Strategy for BaselineServerConfig
2814    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    // Strategy for ConfigFile
2832    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    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
2846    //
2847    // For any valid ConfigFile, serializing to JSON then deserializing
2848    // SHALL produce an equivalent value.
2849    //
2850    // **Validates: Requirements 4.2, 4.5**
2851    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            // Serialize to JSON
2857            let json = serde_json::to_string(&config)
2858                .expect("ConfigFile should serialize to JSON");
2859
2860            // Deserialize back
2861            let deserialized: ConfigFile = serde_json::from_str(&json)
2862                .expect("JSON should deserialize back to ConfigFile");
2863
2864            // Compare defaults
2865            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            // Compare f64 fields in defaults with tolerance
2879            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            // Compare benches
2904            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                // Compare budgets map with f64 tolerance
2914                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                            // Compare threshold with tolerance
2922                            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                            // Compare warn_factor with tolerance
2937                            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    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip (TOML variant)**
2958    //
2959    // For any valid ConfigFile, serializing to TOML then deserializing
2960    // SHALL produce an equivalent value.
2961    //
2962    // **Validates: Requirements 4.2, 4.5**
2963    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            // Serialize to TOML
2969            let toml_str = toml::to_string(&config)
2970                .expect("ConfigFile should serialize to TOML");
2971
2972            // Deserialize back
2973            let deserialized: ConfigFile = toml::from_str(&toml_str)
2974                .expect("TOML should deserialize back to ConfigFile");
2975
2976            // Compare defaults
2977            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            // Compare f64 fields in defaults with tolerance
2991            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            // Compare benches
3016            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                // Compare budgets map with f64 tolerance
3026                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                            // Compare threshold with tolerance
3034                            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                            // Compare warn_factor with tolerance
3049                            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    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
3070    //
3071    // For any valid BenchConfigFile, serializing to JSON then deserializing
3072    // SHALL produce an equivalent value. This tests the BenchConfigFile type
3073    // in isolation with all optional fields.
3074    //
3075    // **Validates: Requirements 4.2, 4.5**
3076    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            // Serialize to JSON
3082            let json = serde_json::to_string(&bench_config)
3083                .expect("BenchConfigFile should serialize to JSON");
3084
3085            // Deserialize back
3086            let deserialized: BenchConfigFile = serde_json::from_str(&json)
3087                .expect("JSON should deserialize back to BenchConfigFile");
3088
3089            // Compare required fields
3090            prop_assert_eq!(&bench_config.name, &deserialized.name);
3091            prop_assert_eq!(&bench_config.command, &deserialized.command);
3092
3093            // Compare optional fields
3094            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            // Compare budgets map with f64 tolerance
3100            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                        // Compare threshold with tolerance
3108                        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                        // Compare warn_factor with tolerance
3123                        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    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip (TOML variant)**
3143    //
3144    // For any valid BenchConfigFile, serializing to TOML then deserializing
3145    // SHALL produce an equivalent value. This tests the BenchConfigFile type
3146    // in isolation with all optional fields.
3147    //
3148    // **Validates: Requirements 4.2, 4.5**
3149    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            // Serialize to TOML
3155            let toml_str = toml::to_string(&bench_config)
3156                .expect("BenchConfigFile should serialize to TOML");
3157
3158            // Deserialize back
3159            let deserialized: BenchConfigFile = toml::from_str(&toml_str)
3160                .expect("TOML should deserialize back to BenchConfigFile");
3161
3162            // Compare required fields
3163            prop_assert_eq!(&bench_config.name, &deserialized.name);
3164            prop_assert_eq!(&bench_config.command, &deserialized.command);
3165
3166            // Compare optional fields
3167            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            // Compare budgets map with f64 tolerance
3173            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                        // Compare threshold with tolerance
3181                        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                        // Compare warn_factor with tolerance
3196                        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    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
3216    //
3217    // For any valid Budget, serializing to JSON then deserializing
3218    // SHALL produce an equivalent value.
3219    //
3220    // **Validates: Requirements 4.2, 4.5**
3221    proptest! {
3222        #![proptest_config(ProptestConfig::with_cases(100))]
3223
3224        #[test]
3225        fn budget_json_serialization_round_trip(budget in budget_strategy()) {
3226            // Serialize to JSON
3227            let json = serde_json::to_string(&budget)
3228                .expect("Budget should serialize to JSON");
3229
3230            // Deserialize back
3231            let deserialized: Budget = serde_json::from_str(&json)
3232                .expect("JSON should deserialize back to Budget");
3233
3234            // Compare f64 fields with tolerance
3235            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    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
3250    //
3251    // For any valid BudgetOverride, serializing to JSON then deserializing
3252    // SHALL produce an equivalent value.
3253    //
3254    // **Validates: Requirements 4.2, 4.5**
3255    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            // Serialize to JSON
3261            let json = serde_json::to_string(&budget_override)
3262                .expect("BudgetOverride should serialize to JSON");
3263
3264            // Deserialize back
3265            let deserialized: BudgetOverride = serde_json::from_str(&json)
3266                .expect("JSON should deserialize back to BudgetOverride");
3267
3268            // Compare threshold with tolerance
3269            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            // Compare direction
3282            prop_assert_eq!(budget_override.direction, deserialized.direction);
3283
3284            // Compare warn_factor with tolerance
3285            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    // **Feature: comprehensive-test-coverage, Property 1: JSON Serialization Round-Trip**
3300    //
3301    // For any valid Budget, the threshold relationship SHALL be preserved:
3302    // warn_threshold <= threshold. This property verifies that the Budget
3303    // strategy generates valid budgets and that serialization preserves
3304    // this invariant.
3305    //
3306    // **Validates: Requirements 4.2, 4.5**
3307    proptest! {
3308        #![proptest_config(ProptestConfig::with_cases(100))]
3309
3310        #[test]
3311        fn budget_threshold_relationship_preserved(budget in budget_strategy()) {
3312            // Verify the invariant holds for the generated budget
3313            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            // Serialize to JSON and back
3320            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            // Verify the invariant is preserved after round-trip
3326            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    // --- Leaf-type round-trip tests ---
3335
3336    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    // --- PerfgateReport round-trip ---
3413
3414    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            // CompareReceipt equality is covered by compare_receipt round-trip;
3518            // just verify presence matches.
3519            prop_assert_eq!(report.compare.is_some(), back.compare.is_some());
3520        }
3521    }
3522}
3523
3524// --- Golden fixture tests for sensor.report.v1 ---
3525
3526#[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        // Round-trip the parsed fixture
3553        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        // Both findings are baseline-missing for different benches
3641        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}