Skip to main content

perfgate_app/
lib.rs

1//! Application layer for perfgate.
2//!
3//! The app layer coordinates adapters and domain logic into use-case workflows
4//! such as run, compare, report, check, export, paired, and promote.
5//! It does not parse CLI flags and it does not do filesystem I/O.
6//!
7//! Part of the [perfgate](https://github.com/EffortlessMetrics/perfgate) workspace.
8
9mod aggregate;
10pub mod badge;
11pub mod baseline_resolve;
12pub mod bisect;
13pub mod blame;
14pub mod cargo_bench;
15mod check;
16pub mod comparison_logic;
17mod diff;
18pub mod discover;
19mod explain;
20pub mod init;
21mod paired;
22mod promote;
23mod report;
24mod sensor_report;
25mod trend;
26pub mod watch;
27
28pub use aggregate::{AggregateOutcome, AggregateRequest, AggregateUseCase};
29pub use badge::{
30    Badge, BadgeInput, BadgeOutcome, BadgeRequest, BadgeStyle, BadgeType, BadgeUseCase,
31};
32pub use bisect::{BisectRequest, BisectUseCase};
33pub use blame::{BlameOutcome, BlameRequest, BlameUseCase};
34pub use check::{CheckOutcome, CheckRequest, CheckUseCase};
35pub use diff::{
36    BenchDiffOutcome, DiffOutcome, DiffRequest, DiffUseCase, discover_config, render_json_diff,
37    render_terminal_diff,
38};
39pub use explain::{ExplainOutcome, ExplainRequest, ExplainUseCase};
40pub use paired::{PairedRunOutcome, PairedRunRequest, PairedRunUseCase};
41pub use promote::{PromoteRequest, PromoteResult, PromoteUseCase};
42pub use report::{ReportRequest, ReportResult, ReportUseCase};
43pub use sensor_report::{
44    BenchOutcome, SensorCheckOptions, SensorReportBuilder, classify_error,
45    default_engine_capability, run_sensor_check, sensor_fingerprint,
46};
47pub use trend::{
48    TrendOutcome, TrendRequest, TrendUseCase, format_trend_chart, format_trend_output,
49};
50
51// Re-export rendering functions from perfgate-render for backward compatibility
52pub use perfgate_render::{
53    direction_str, format_metric, format_metric_with_statistic, format_pct, format_value,
54    github_annotations, markdown_template_context, metric_status_icon, metric_status_str,
55    parse_reason_token, render_markdown, render_markdown_template, render_reason_line,
56};
57
58// Re-export export functionality from perfgate-export for backward compatibility
59pub use perfgate_export::{CompareExportRow, ExportFormat, ExportUseCase, RunExportRow};
60
61use perfgate_adapters::{CommandSpec, HostProbe, HostProbeOptions, ProcessRunner, RunResult};
62use perfgate_domain::{
63    Comparison, SignificancePolicy, compare_runs, compute_stats, detect_host_mismatch,
64};
65use perfgate_types::{
66    BenchMeta, Budget, CompareReceipt, CompareRef, HostMismatchInfo, HostMismatchPolicy, Metric,
67    MetricStatistic, RunMeta, RunReceipt, Sample, ToolInfo,
68};
69use std::collections::BTreeMap;
70use std::path::PathBuf;
71use std::time::Duration;
72
73pub trait Clock: Send + Sync {
74    fn now_rfc3339(&self) -> String;
75}
76
77#[derive(Debug, Default, Clone)]
78pub struct SystemClock;
79
80impl Clock for SystemClock {
81    fn now_rfc3339(&self) -> String {
82        use time::format_description::well_known::Rfc3339;
83        time::OffsetDateTime::now_utc()
84            .format(&Rfc3339)
85            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
86    }
87}
88
89#[derive(Debug, Clone, Default)]
90pub struct RunBenchRequest {
91    pub name: String,
92    pub cwd: Option<PathBuf>,
93    pub command: Vec<String>,
94    pub repeat: u32,
95    pub warmup: u32,
96    pub work_units: Option<u64>,
97    pub timeout: Option<Duration>,
98    pub env: Vec<(String, String)>,
99    pub output_cap_bytes: usize,
100
101    /// If true, do not treat nonzero exit codes as a tool error.
102    /// The receipt will still record exit codes.
103    pub allow_nonzero: bool,
104
105    /// If true, include a hashed hostname in the host fingerprint.
106    /// This is opt-in for privacy reasons.
107    pub include_hostname_hash: bool,
108}
109
110#[derive(Debug, Clone)]
111pub struct RunBenchOutcome {
112    pub receipt: RunReceipt,
113
114    /// True if any measured (non-warmup) sample timed out or returned nonzero.
115    pub failed: bool,
116
117    /// Human-readable reasons (for CI logs).
118    pub reasons: Vec<String>,
119}
120
121pub struct RunBenchUseCase<R: ProcessRunner, H: HostProbe, C: Clock> {
122    runner: R,
123    host_probe: H,
124    clock: C,
125    tool: ToolInfo,
126}
127
128impl<R: ProcessRunner, H: HostProbe, C: Clock> RunBenchUseCase<R, H, C> {
129    pub fn new(runner: R, host_probe: H, clock: C, tool: ToolInfo) -> Self {
130        Self {
131            runner,
132            host_probe,
133            clock,
134            tool,
135        }
136    }
137
138    pub fn execute(&self, req: RunBenchRequest) -> anyhow::Result<RunBenchOutcome> {
139        let run_id = uuid::Uuid::new_v4().to_string();
140        let started_at = self.clock.now_rfc3339();
141
142        let host_options = HostProbeOptions {
143            include_hostname_hash: req.include_hostname_hash,
144        };
145        let host = self.host_probe.probe(&host_options);
146
147        let bench = BenchMeta {
148            name: req.name.clone(),
149            cwd: req.cwd.as_ref().map(|p| p.to_string_lossy().to_string()),
150            command: req.command.clone(),
151            repeat: req.repeat,
152            warmup: req.warmup,
153            work_units: req.work_units,
154            timeout_ms: req.timeout.map(|d| d.as_millis() as u64),
155        };
156
157        let mut samples: Vec<Sample> = Vec::new();
158        let mut reasons: Vec<String> = Vec::new();
159
160        let total = req.warmup + req.repeat;
161
162        for i in 0..total {
163            let is_warmup = i < req.warmup;
164
165            let spec = CommandSpec {
166                name: req.name.clone(),
167                argv: req.command.clone(),
168                cwd: req.cwd.clone(),
169                env: req.env.clone(),
170                timeout: req.timeout,
171                output_cap_bytes: req.output_cap_bytes,
172            };
173
174            let run = self.runner.run(&spec).map_err(|e| match e {
175                perfgate_adapters::AdapterError::RunCommand { command, reason } => {
176                    anyhow::anyhow!("failed to run iteration {}: {}: {}", i + 1, command, reason)
177                }
178                _ => anyhow::anyhow!("failed to run iteration {}: {}", i + 1, e),
179            })?;
180
181            let s = sample_from_run(run, is_warmup);
182            if !is_warmup {
183                if s.timed_out {
184                    reasons.push(format!("iteration {} timed out", i + 1));
185                }
186                if s.exit_code != 0 {
187                    reasons.push(format!("iteration {} exit code {}", i + 1, s.exit_code));
188                }
189            }
190
191            samples.push(s);
192        }
193
194        let stats = compute_stats(&samples, req.work_units)?;
195
196        let ended_at = self.clock.now_rfc3339();
197
198        let receipt = RunReceipt {
199            schema: perfgate_types::RUN_SCHEMA_V1.to_string(),
200            tool: self.tool.clone(),
201            run: RunMeta {
202                id: run_id,
203                started_at,
204                ended_at,
205                host,
206            },
207            bench,
208            samples,
209            stats,
210        };
211
212        let failed = !reasons.is_empty();
213
214        if failed && !req.allow_nonzero {
215            // It's still a successful run from a *tooling* perspective, but callers may want a hard failure.
216            // We return the receipt either way; the CLI decides exit codes.
217        }
218
219        Ok(RunBenchOutcome {
220            receipt,
221            failed,
222            reasons,
223        })
224    }
225}
226
227fn sample_from_run(run: RunResult, warmup: bool) -> Sample {
228    Sample {
229        wall_ms: run.wall_ms,
230        exit_code: run.exit_code,
231        warmup,
232        timed_out: run.timed_out,
233        cpu_ms: run.cpu_ms,
234        page_faults: run.page_faults,
235        ctx_switches: run.ctx_switches,
236        max_rss_kb: run.max_rss_kb,
237        io_read_bytes: run.io_read_bytes,
238        io_write_bytes: run.io_write_bytes,
239        network_packets: run.network_packets,
240        energy_uj: run.energy_uj,
241        binary_bytes: run.binary_bytes,
242        stdout: if run.stdout.is_empty() {
243            None
244        } else {
245            Some(String::from_utf8_lossy(&run.stdout).to_string())
246        },
247        stderr: if run.stderr.is_empty() {
248            None
249        } else {
250            Some(String::from_utf8_lossy(&run.stderr).to_string())
251        },
252    }
253}
254
255#[derive(Debug, Clone)]
256pub struct CompareRequest {
257    pub baseline: RunReceipt,
258    pub current: RunReceipt,
259    pub budgets: BTreeMap<Metric, Budget>,
260    pub metric_statistics: BTreeMap<Metric, MetricStatistic>,
261    pub significance: Option<SignificancePolicy>,
262    pub baseline_ref: CompareRef,
263    pub current_ref: CompareRef,
264    pub tool: ToolInfo,
265    /// Policy for handling host mismatches.
266    #[allow(dead_code)]
267    pub host_mismatch_policy: HostMismatchPolicy,
268}
269
270/// Result from CompareUseCase including host mismatch information.
271#[derive(Debug, Clone)]
272pub struct CompareResult {
273    pub receipt: CompareReceipt,
274    /// Host mismatch info if detected (only populated when policy is not Ignore).
275    pub host_mismatch: Option<HostMismatchInfo>,
276}
277
278pub struct CompareUseCase;
279
280impl CompareUseCase {
281    pub fn execute(req: CompareRequest) -> anyhow::Result<CompareResult> {
282        // Check for host mismatch
283        let host_mismatch = if req.host_mismatch_policy != HostMismatchPolicy::Ignore {
284            detect_host_mismatch(&req.baseline.run.host, &req.current.run.host)
285        } else {
286            None
287        };
288
289        // If policy is Error and there's a mismatch, fail immediately
290        if req.host_mismatch_policy == HostMismatchPolicy::Error
291            && let Some(mismatch) = &host_mismatch
292        {
293            anyhow::bail!(
294                "host mismatch detected (--host-mismatch=error): {}",
295                mismatch.reasons.join("; ")
296            );
297        }
298
299        let Comparison { deltas, verdict } = compare_runs(
300            &req.baseline,
301            &req.current,
302            &req.budgets,
303            &req.metric_statistics,
304            req.significance,
305        )?;
306
307        let receipt = CompareReceipt {
308            schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
309            tool: req.tool,
310            bench: req.current.bench,
311            baseline_ref: req.baseline_ref,
312            current_ref: req.current_ref,
313            budgets: req.budgets,
314            deltas,
315            verdict,
316        };
317
318        Ok(CompareResult {
319            receipt,
320            host_mismatch,
321        })
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use perfgate_types::{
329        Delta, Direction, HostInfo, MetricStatistic, MetricStatus, RUN_SCHEMA_V1, RunMeta,
330        RunReceipt, Stats, U64Summary, Verdict, VerdictCounts, VerdictStatus,
331    };
332    use std::collections::BTreeMap;
333
334    fn make_compare_receipt(status: MetricStatus) -> CompareReceipt {
335        let mut budgets = BTreeMap::new();
336        budgets.insert(
337            Metric::WallMs,
338            Budget {
339                threshold: 0.2,
340                warn_threshold: 0.1,
341                noise_threshold: None,
342                noise_policy: perfgate_types::NoisePolicy::Ignore,
343                direction: Direction::Lower,
344            },
345        );
346
347        let mut deltas = BTreeMap::new();
348        deltas.insert(
349            Metric::WallMs,
350            Delta {
351                baseline: 100.0,
352                current: 115.0,
353                ratio: 1.15,
354                pct: 0.15,
355                regression: 0.15,
356                cv: None,
357                noise_threshold: None,
358                statistic: MetricStatistic::Median,
359                significance: None,
360                status,
361            },
362        );
363
364        CompareReceipt {
365            schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
366            tool: ToolInfo {
367                name: "perfgate".into(),
368                version: "0.1.0".into(),
369            },
370            bench: BenchMeta {
371                name: "bench".into(),
372                cwd: None,
373                command: vec!["true".into()],
374                repeat: 1,
375                warmup: 0,
376                work_units: None,
377                timeout_ms: None,
378            },
379            baseline_ref: CompareRef {
380                path: None,
381                run_id: None,
382            },
383            current_ref: CompareRef {
384                path: None,
385                run_id: None,
386            },
387            budgets,
388            deltas,
389            verdict: Verdict {
390                status: VerdictStatus::Warn,
391                counts: VerdictCounts {
392                    pass: if status == MetricStatus::Pass { 1 } else { 0 },
393                    warn: if status == MetricStatus::Warn { 1 } else { 0 },
394                    fail: if status == MetricStatus::Fail { 1 } else { 0 },
395                    skip: if status == MetricStatus::Skip { 1 } else { 0 },
396                },
397                reasons: vec!["wall_ms_warn".to_string()],
398            },
399        }
400    }
401
402    fn make_run_receipt_with_host(host: HostInfo, wall_ms: u64) -> RunReceipt {
403        RunReceipt {
404            schema: RUN_SCHEMA_V1.to_string(),
405            tool: ToolInfo {
406                name: "perfgate".to_string(),
407                version: "0.1.0".to_string(),
408            },
409            run: RunMeta {
410                id: "run-id".to_string(),
411                started_at: "2024-01-01T00:00:00Z".to_string(),
412                ended_at: "2024-01-01T00:00:01Z".to_string(),
413                host,
414            },
415            bench: BenchMeta {
416                name: "bench".to_string(),
417                cwd: None,
418                command: vec!["true".to_string()],
419                repeat: 1,
420                warmup: 0,
421                work_units: None,
422                timeout_ms: None,
423            },
424            samples: Vec::new(),
425            stats: Stats {
426                wall_ms: U64Summary::new(wall_ms, wall_ms, wall_ms),
427                cpu_ms: None,
428                page_faults: None,
429                ctx_switches: None,
430                max_rss_kb: None,
431                io_read_bytes: None,
432                io_write_bytes: None,
433                network_packets: None,
434                energy_uj: None,
435                binary_bytes: None,
436                throughput_per_s: None,
437            },
438        }
439    }
440
441    #[test]
442    fn markdown_renders_table() {
443        let mut budgets = BTreeMap::new();
444        budgets.insert(
445            Metric::WallMs,
446            Budget {
447                threshold: 0.2,
448                warn_threshold: 0.18,
449                noise_threshold: None,
450                noise_policy: perfgate_types::NoisePolicy::Ignore,
451                direction: Direction::Lower,
452            },
453        );
454
455        let mut deltas = BTreeMap::new();
456        deltas.insert(
457            Metric::WallMs,
458            Delta {
459                baseline: 1000.0,
460                current: 1100.0,
461                ratio: 1.1,
462                pct: 0.1,
463                regression: 0.1,
464                cv: None,
465                noise_threshold: None,
466                statistic: MetricStatistic::Median,
467                significance: None,
468                status: MetricStatus::Pass,
469            },
470        );
471
472        let compare = CompareReceipt {
473            schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
474            tool: ToolInfo {
475                name: "perfgate".into(),
476                version: "0.1.0".into(),
477            },
478            bench: BenchMeta {
479                name: "demo".into(),
480                cwd: None,
481                command: vec!["true".into()],
482                repeat: 1,
483                warmup: 0,
484                work_units: None,
485                timeout_ms: None,
486            },
487            baseline_ref: CompareRef {
488                path: None,
489                run_id: None,
490            },
491            current_ref: CompareRef {
492                path: None,
493                run_id: None,
494            },
495            budgets,
496            deltas,
497            verdict: Verdict {
498                status: VerdictStatus::Pass,
499                counts: VerdictCounts {
500                    pass: 1,
501                    warn: 0,
502                    fail: 0,
503                    skip: 0,
504                },
505                reasons: vec![],
506            },
507        };
508
509        let md = render_markdown(&compare);
510        assert!(md.contains("| metric | baseline"));
511        assert!(md.contains("wall_ms"));
512    }
513
514    #[test]
515    fn markdown_template_renders_context_rows() {
516        let compare = make_compare_receipt(MetricStatus::Warn);
517        let template = "{{header}}\nbench={{bench.name}}\n{{#each rows}}metric={{metric}} status={{status}}\n{{/each}}";
518
519        let rendered = render_markdown_template(&compare, template).expect("render template");
520        assert!(rendered.contains("bench=bench"));
521        assert!(rendered.contains("metric=wall_ms"));
522        assert!(rendered.contains("status=warn"));
523    }
524
525    #[test]
526    fn markdown_template_strict_mode_rejects_unknown_fields() {
527        let compare = make_compare_receipt(MetricStatus::Warn);
528        let err = render_markdown_template(&compare, "{{does_not_exist}}").unwrap_err();
529        assert!(
530            err.to_string().contains("render markdown template"),
531            "unexpected error: {}",
532            err
533        );
534    }
535
536    #[test]
537    fn parse_reason_token_handles_valid_and_invalid() {
538        let parsed = parse_reason_token("wall_ms_warn");
539        assert!(parsed.is_some());
540        let (metric, status) = parsed.unwrap();
541        assert_eq!(metric, Metric::WallMs);
542        assert_eq!(status, MetricStatus::Warn);
543
544        assert!(parse_reason_token("wall_ms_pass").is_none());
545        assert!(parse_reason_token("unknown_warn").is_none());
546    }
547
548    #[test]
549    fn render_reason_line_formats_thresholds() {
550        let compare = make_compare_receipt(MetricStatus::Warn);
551        let line = render_reason_line(&compare, "wall_ms_warn");
552        assert!(line.contains("warn >="));
553        assert!(line.contains("fail >"));
554        assert!(line.contains("+15.00%"));
555    }
556
557    #[test]
558    fn render_reason_line_falls_back_when_missing_budget() {
559        let mut compare = make_compare_receipt(MetricStatus::Warn);
560        compare.budgets.clear();
561        let line = render_reason_line(&compare, "wall_ms_warn");
562        assert_eq!(line, "- wall_ms_warn\n");
563    }
564
565    #[test]
566    fn format_value_and_pct_render_expected_strings() {
567        assert_eq!(format_value(Metric::ThroughputPerS, 1.23456), "1.235");
568        assert_eq!(format_value(Metric::WallMs, 123.0), "123");
569        assert_eq!(format_pct(0.1), "+10.00%");
570        assert_eq!(format_pct(-0.1), "-10.00%");
571        assert_eq!(format_pct(0.0), "0.00%");
572    }
573
574    #[test]
575    fn github_annotations_only_warn_and_fail() {
576        let mut compare = make_compare_receipt(MetricStatus::Warn);
577        compare.deltas.insert(
578            Metric::MaxRssKb,
579            Delta {
580                baseline: 100.0,
581                current: 150.0,
582                ratio: 1.5,
583                pct: 0.5,
584                regression: 0.5,
585                cv: None,
586                noise_threshold: None,
587                statistic: MetricStatistic::Median,
588                significance: None,
589                status: MetricStatus::Fail,
590            },
591        );
592        compare.deltas.insert(
593            Metric::ThroughputPerS,
594            Delta {
595                baseline: 100.0,
596                current: 90.0,
597                ratio: 0.9,
598                pct: -0.1,
599                regression: 0.0,
600                cv: None,
601                noise_threshold: None,
602                statistic: MetricStatistic::Median,
603                significance: None,
604                status: MetricStatus::Pass,
605            },
606        );
607
608        let lines = github_annotations(&compare);
609        assert_eq!(lines.len(), 2);
610        assert!(lines.iter().any(|l| l.starts_with("::warning::")));
611        assert!(lines.iter().any(|l| l.starts_with("::error::")));
612        assert!(lines.iter().all(|l| !l.contains("throughput_per_s")));
613    }
614
615    #[test]
616    fn sample_from_run_sets_optional_stdout_stderr() {
617        let run = RunResult {
618            wall_ms: 100,
619            exit_code: 0,
620            timed_out: false,
621            cpu_ms: None,
622            page_faults: None,
623            ctx_switches: None,
624            max_rss_kb: None,
625            io_read_bytes: None,
626            io_write_bytes: None,
627            network_packets: None,
628            energy_uj: None,
629            binary_bytes: None,
630            stdout: b"ok".to_vec(),
631            stderr: vec![],
632        };
633
634        let sample = sample_from_run(run, false);
635        assert_eq!(sample.stdout.as_deref(), Some("ok"));
636        assert!(sample.stderr.is_none());
637    }
638
639    #[test]
640    fn compare_use_case_host_mismatch_policies() {
641        let baseline = make_run_receipt_with_host(
642            HostInfo {
643                os: "linux".to_string(),
644                arch: "x86_64".to_string(),
645                cpu_count: None,
646                memory_bytes: None,
647                hostname_hash: None,
648            },
649            100,
650        );
651        let current = make_run_receipt_with_host(
652            HostInfo {
653                os: "windows".to_string(),
654                arch: "x86_64".to_string(),
655                cpu_count: None,
656                memory_bytes: None,
657                hostname_hash: None,
658            },
659            100,
660        );
661
662        let mut budgets = BTreeMap::new();
663        budgets.insert(
664            Metric::WallMs,
665            Budget {
666                threshold: 0.2,
667                warn_threshold: 0.1,
668                noise_threshold: None,
669                noise_policy: perfgate_types::NoisePolicy::Ignore,
670                direction: Direction::Lower,
671            },
672        );
673
674        let err = CompareUseCase::execute(CompareRequest {
675            baseline: baseline.clone(),
676            current: current.clone(),
677            budgets: budgets.clone(),
678            metric_statistics: BTreeMap::new(),
679            significance: None,
680            baseline_ref: CompareRef {
681                path: None,
682                run_id: None,
683            },
684            current_ref: CompareRef {
685                path: None,
686                run_id: None,
687            },
688            tool: ToolInfo {
689                name: "perfgate".to_string(),
690                version: "0.1.0".to_string(),
691            },
692            host_mismatch_policy: HostMismatchPolicy::Error,
693        })
694        .unwrap_err();
695        assert!(err.to_string().contains("host mismatch"));
696
697        let matching = CompareUseCase::execute(CompareRequest {
698            baseline: baseline.clone(),
699            current: baseline.clone(),
700            budgets: budgets.clone(),
701            metric_statistics: BTreeMap::new(),
702            significance: None,
703            baseline_ref: CompareRef {
704                path: None,
705                run_id: None,
706            },
707            current_ref: CompareRef {
708                path: None,
709                run_id: None,
710            },
711            tool: ToolInfo {
712                name: "perfgate".to_string(),
713                version: "0.1.0".to_string(),
714            },
715            host_mismatch_policy: HostMismatchPolicy::Error,
716        })
717        .expect("matching hosts should not error");
718        assert!(matching.host_mismatch.is_none());
719
720        let ignore = CompareUseCase::execute(CompareRequest {
721            baseline,
722            current,
723            budgets,
724            metric_statistics: BTreeMap::new(),
725            significance: None,
726            baseline_ref: CompareRef {
727                path: None,
728                run_id: None,
729            },
730            current_ref: CompareRef {
731                path: None,
732                run_id: None,
733            },
734            tool: ToolInfo {
735                name: "perfgate".to_string(),
736                version: "0.1.0".to_string(),
737            },
738            host_mismatch_policy: HostMismatchPolicy::Ignore,
739        })
740        .expect("ignore mismatch should succeed");
741
742        assert!(ignore.host_mismatch.is_none());
743    }
744}
745
746#[cfg(test)]
747mod property_tests {
748    use super::*;
749    use perfgate_types::{
750        Delta, Direction, MetricStatistic, MetricStatus, Verdict, VerdictCounts, VerdictStatus,
751    };
752    use proptest::prelude::*;
753
754    // --- Strategies for generating CompareReceipt ---
755
756    // Strategy for generating valid non-empty strings (for names, IDs, etc.)
757    fn non_empty_string() -> impl Strategy<Value = String> {
758        "[a-zA-Z0-9_-]{1,20}".prop_map(|s| s)
759    }
760
761    // Strategy for ToolInfo
762    fn tool_info_strategy() -> impl Strategy<Value = ToolInfo> {
763        (non_empty_string(), non_empty_string())
764            .prop_map(|(name, version)| ToolInfo { name, version })
765    }
766
767    // Strategy for BenchMeta
768    fn bench_meta_strategy() -> impl Strategy<Value = BenchMeta> {
769        (
770            non_empty_string(),
771            proptest::option::of(non_empty_string()),
772            proptest::collection::vec(non_empty_string(), 1..5),
773            1u32..100,
774            0u32..10,
775            proptest::option::of(1u64..10000),
776            proptest::option::of(100u64..60000),
777        )
778            .prop_map(
779                |(name, cwd, command, repeat, warmup, work_units, timeout_ms)| BenchMeta {
780                    name,
781                    cwd,
782                    command,
783                    repeat,
784                    warmup,
785                    work_units,
786                    timeout_ms,
787                },
788            )
789    }
790
791    // Strategy for CompareRef
792    fn compare_ref_strategy() -> impl Strategy<Value = CompareRef> {
793        (
794            proptest::option::of(non_empty_string()),
795            proptest::option::of(non_empty_string()),
796        )
797            .prop_map(|(path, run_id)| CompareRef { path, run_id })
798    }
799
800    // Strategy for Direction
801    fn direction_strategy() -> impl Strategy<Value = Direction> {
802        prop_oneof![Just(Direction::Lower), Just(Direction::Higher),]
803    }
804
805    // Strategy for Budget - using finite positive floats for thresholds
806    fn budget_strategy() -> impl Strategy<Value = Budget> {
807        (0.01f64..1.0, 0.01f64..1.0, direction_strategy()).prop_map(
808            |(threshold, warn_factor, direction)| {
809                // warn_threshold should be <= threshold
810                let warn_threshold = threshold * warn_factor;
811                Budget {
812                    noise_threshold: None,
813                    noise_policy: perfgate_types::NoisePolicy::Ignore,
814                    threshold,
815                    warn_threshold,
816                    direction,
817                }
818            },
819        )
820    }
821
822    // Strategy for MetricStatus
823    fn metric_status_strategy() -> impl Strategy<Value = MetricStatus> {
824        prop_oneof![
825            Just(MetricStatus::Pass),
826            Just(MetricStatus::Warn),
827            Just(MetricStatus::Fail),
828            Just(MetricStatus::Skip),
829        ]
830    }
831
832    // Strategy for Delta - using finite positive floats
833    fn delta_strategy() -> impl Strategy<Value = Delta> {
834        (
835            0.1f64..10000.0, // baseline (positive, non-zero)
836            0.1f64..10000.0, // current (positive, non-zero)
837            metric_status_strategy(),
838        )
839            .prop_map(|(baseline, current, status)| {
840                let ratio = current / baseline;
841                let pct = (current - baseline) / baseline;
842                let regression = if pct > 0.0 { pct } else { 0.0 };
843                Delta {
844                    baseline,
845                    current,
846                    ratio,
847                    pct,
848                    regression,
849                    cv: None,
850                    noise_threshold: None,
851                    statistic: MetricStatistic::Median,
852                    significance: None,
853                    status,
854                }
855            })
856    }
857
858    // Strategy for VerdictStatus
859    fn verdict_status_strategy() -> impl Strategy<Value = VerdictStatus> {
860        prop_oneof![
861            Just(VerdictStatus::Pass),
862            Just(VerdictStatus::Warn),
863            Just(VerdictStatus::Fail),
864            Just(VerdictStatus::Skip),
865        ]
866    }
867
868    // Strategy for VerdictCounts
869    fn verdict_counts_strategy() -> impl Strategy<Value = VerdictCounts> {
870        (0u32..10, 0u32..10, 0u32..10, 0u32..10).prop_map(|(pass, warn, fail, skip)| {
871            VerdictCounts {
872                pass,
873                warn,
874                fail,
875                skip,
876            }
877        })
878    }
879
880    // Strategy for Verdict with reasons
881    fn verdict_strategy() -> impl Strategy<Value = Verdict> {
882        (
883            verdict_status_strategy(),
884            verdict_counts_strategy(),
885            proptest::collection::vec("[a-zA-Z0-9 ]{1,50}", 0..5),
886        )
887            .prop_map(|(status, counts, reasons)| Verdict {
888                status,
889                counts,
890                reasons,
891            })
892    }
893
894    // Strategy for Metric
895    fn metric_strategy() -> impl Strategy<Value = Metric> {
896        prop_oneof![
897            Just(Metric::BinaryBytes),
898            Just(Metric::CpuMs),
899            Just(Metric::CtxSwitches),
900            Just(Metric::IoReadBytes),
901            Just(Metric::IoWriteBytes),
902            Just(Metric::WallMs),
903            Just(Metric::MaxRssKb),
904            Just(Metric::NetworkPackets),
905            Just(Metric::PageFaults),
906            Just(Metric::ThroughputPerS),
907        ]
908    }
909
910    // Strategy for BTreeMap<Metric, Budget>
911    fn budgets_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Budget>> {
912        proptest::collection::btree_map(metric_strategy(), budget_strategy(), 0..8)
913    }
914
915    // Strategy for BTreeMap<Metric, Delta>
916    fn deltas_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Delta>> {
917        proptest::collection::btree_map(metric_strategy(), delta_strategy(), 0..8)
918    }
919
920    // Strategy for CompareReceipt
921    fn compare_receipt_strategy() -> impl Strategy<Value = CompareReceipt> {
922        (
923            tool_info_strategy(),
924            bench_meta_strategy(),
925            compare_ref_strategy(),
926            compare_ref_strategy(),
927            budgets_map_strategy(),
928            deltas_map_strategy(),
929            verdict_strategy(),
930        )
931            .prop_map(
932                |(tool, bench, baseline_ref, current_ref, budgets, deltas, verdict)| {
933                    CompareReceipt {
934                        schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
935                        tool,
936                        bench,
937                        baseline_ref,
938                        current_ref,
939                        budgets,
940                        deltas,
941                        verdict,
942                    }
943                },
944            )
945    }
946
947    // **Property 6: Markdown Rendering Completeness**
948    //
949    // For any valid CompareReceipt, the rendered Markdown SHALL contain:
950    // - A header with the correct verdict emoji (✅ for Pass, ⚠️ for Warn, ❌ for Fail)
951    // - The benchmark name
952    // - A table row for each metric in deltas
953    // - All verdict reasons (if any)
954    //
955    // **Validates: Requirements 7.2, 7.3, 7.4, 7.5**
956    proptest! {
957        #![proptest_config(ProptestConfig::with_cases(100))]
958
959        #[test]
960        fn markdown_rendering_completeness(receipt in compare_receipt_strategy()) {
961            let md = render_markdown(&receipt);
962
963            // Verify header contains correct verdict emoji (Requirement 7.2)
964            let expected_emoji = match receipt.verdict.status {
965                VerdictStatus::Pass => "✅",
966                VerdictStatus::Warn => "⚠️",
967                VerdictStatus::Fail => "❌",
968                VerdictStatus::Skip => "⏭️",
969            };
970            prop_assert!(
971                md.contains(expected_emoji),
972                "Markdown should contain verdict emoji '{}' for status {:?}. Got:\n{}",
973                expected_emoji,
974                receipt.verdict.status,
975                md
976            );
977
978            // Verify header contains "perfgate" and verdict status word
979            let expected_status_word = match receipt.verdict.status {
980                VerdictStatus::Pass => "pass",
981                VerdictStatus::Warn => "warn",
982                VerdictStatus::Fail => "fail",
983                VerdictStatus::Skip => "skip",
984            };
985            prop_assert!(
986                md.contains(expected_status_word),
987                "Markdown should contain status word '{}'. Got:\n{}",
988                expected_status_word,
989                md
990            );
991
992            // Verify benchmark name is present (Requirement 7.3)
993            prop_assert!(
994                md.contains(&receipt.bench.name),
995                "Markdown should contain benchmark name '{}'. Got:\n{}",
996                receipt.bench.name,
997                md
998            );
999
1000            // Verify table header is present (Requirement 7.4)
1001            prop_assert!(
1002                md.contains("| metric |"),
1003                "Markdown should contain table header. Got:\n{}",
1004                md
1005            );
1006
1007            // Verify a table row exists for each metric in deltas (Requirement 7.4)
1008            for metric in receipt.deltas.keys() {
1009                let metric_name = metric.as_str();
1010                prop_assert!(
1011                    md.contains(metric_name),
1012                    "Markdown should contain metric '{}'. Got:\n{}",
1013                    metric_name,
1014                    md
1015                );
1016            }
1017
1018            // Verify all verdict reasons are present (Requirement 7.5)
1019            for reason in &receipt.verdict.reasons {
1020                prop_assert!(
1021                    md.contains(reason),
1022                    "Markdown should contain verdict reason '{}'. Got:\n{}",
1023                    reason,
1024                    md
1025                );
1026            }
1027
1028            // If there are reasons, verify the Notes section exists
1029            if !receipt.verdict.reasons.is_empty() {
1030                prop_assert!(
1031                    md.contains("**Notes:**"),
1032                    "Markdown should contain Notes section when there are reasons. Got:\n{}",
1033                    md
1034                );
1035            }
1036        }
1037    }
1038
1039    // **Property 7: GitHub Annotation Generation**
1040    //
1041    // For any valid CompareReceipt:
1042    // - Metrics with Fail status SHALL produce exactly one `::error::` annotation
1043    // - Metrics with Warn status SHALL produce exactly one `::warning::` annotation
1044    // - Metrics with Pass status SHALL produce no annotations
1045    // - Each annotation SHALL contain the bench name, metric name, and delta percentage
1046    //
1047    // **Validates: Requirements 8.2, 8.3, 8.4, 8.5**
1048    proptest! {
1049        #![proptest_config(ProptestConfig::with_cases(100))]
1050
1051        #[test]
1052        fn github_annotation_generation(receipt in compare_receipt_strategy()) {
1053            let annotations = github_annotations(&receipt);
1054
1055            // Count expected annotations by status
1056            let expected_fail_count = receipt.deltas.values()
1057                .filter(|d| d.status == MetricStatus::Fail)
1058                .count();
1059            let expected_warn_count = receipt.deltas.values()
1060                .filter(|d| d.status == MetricStatus::Warn)
1061                .count();
1062            let expected_pass_count = receipt.deltas.values()
1063                .filter(|d| d.status == MetricStatus::Pass)
1064                .count();
1065
1066            // Count actual annotations by type
1067            let actual_error_count = annotations.iter()
1068                .filter(|a| a.starts_with("::error::"))
1069                .count();
1070            let actual_warning_count = annotations.iter()
1071                .filter(|a| a.starts_with("::warning::"))
1072                .count();
1073
1074            // Requirement 8.2: Fail status produces exactly one ::error:: annotation
1075            prop_assert_eq!(
1076                actual_error_count,
1077                expected_fail_count,
1078                "Expected {} ::error:: annotations for {} Fail metrics, got {}. Annotations: {:?}",
1079                expected_fail_count,
1080                expected_fail_count,
1081                actual_error_count,
1082                annotations
1083            );
1084
1085            // Requirement 8.3: Warn status produces exactly one ::warning:: annotation
1086            prop_assert_eq!(
1087                actual_warning_count,
1088                expected_warn_count,
1089                "Expected {} ::warning:: annotations for {} Warn metrics, got {}. Annotations: {:?}",
1090                expected_warn_count,
1091                expected_warn_count,
1092                actual_warning_count,
1093                annotations
1094            );
1095
1096            // Requirement 8.4: Pass status produces no annotations
1097            // Total annotations should equal fail + warn count (no pass annotations)
1098            let total_annotations = annotations.len();
1099            let expected_total = expected_fail_count + expected_warn_count;
1100            prop_assert_eq!(
1101                total_annotations,
1102                expected_total,
1103                "Expected {} total annotations (fail: {}, warn: {}, pass: {} should produce none), got {}. Annotations: {:?}",
1104                expected_total,
1105                expected_fail_count,
1106                expected_warn_count,
1107                expected_pass_count,
1108                total_annotations,
1109                annotations
1110            );
1111
1112            // Requirement 8.5: Each annotation contains bench name, metric name, and delta percentage
1113            for (metric, delta) in &receipt.deltas {
1114                if delta.status == MetricStatus::Pass || delta.status == MetricStatus::Skip {
1115                    continue; // Pass metrics don't produce annotations
1116                }
1117
1118                let metric_name = metric.as_str();
1119
1120                // Find the annotation for this metric
1121                let matching_annotation = annotations.iter().find(|a| a.contains(metric_name));
1122
1123                prop_assert!(
1124                    matching_annotation.is_some(),
1125                    "Expected annotation for metric '{}' with status {:?}. Annotations: {:?}",
1126                    metric_name,
1127                    delta.status,
1128                    annotations
1129                );
1130
1131                let annotation = matching_annotation.unwrap();
1132
1133                // Verify annotation contains bench name
1134                prop_assert!(
1135                    annotation.contains(&receipt.bench.name),
1136                    "Annotation should contain bench name '{}'. Got: {}",
1137                    receipt.bench.name,
1138                    annotation
1139                );
1140
1141                // Verify annotation contains metric name
1142                prop_assert!(
1143                    annotation.contains(metric_name),
1144                    "Annotation should contain metric name '{}'. Got: {}",
1145                    metric_name,
1146                    annotation
1147                );
1148
1149                // Verify annotation contains delta percentage (formatted as +X.XX% or -X.XX%)
1150                // The format_pct function produces strings like "+10.00%" or "-5.50%"
1151                let pct_str = format_pct(delta.pct);
1152                prop_assert!(
1153                    annotation.contains(&pct_str),
1154                    "Annotation should contain delta percentage '{}'. Got: {}",
1155                    pct_str,
1156                    annotation
1157                );
1158
1159                // Verify correct annotation type based on status
1160                match delta.status {
1161                    MetricStatus::Fail => {
1162                        prop_assert!(
1163                            annotation.starts_with("::error::"),
1164                            "Fail metric should produce ::error:: annotation. Got: {}",
1165                            annotation
1166                        );
1167                    }
1168                    MetricStatus::Warn => {
1169                        prop_assert!(
1170                            annotation.starts_with("::warning::"),
1171                            "Warn metric should produce ::warning:: annotation. Got: {}",
1172                            annotation
1173                        );
1174                    }
1175                    MetricStatus::Pass | MetricStatus::Skip => unreachable!(),
1176                }
1177            }
1178        }
1179    }
1180}