1use crate::{
12 Clock, CompareRequest, CompareUseCase, RunBenchRequest, RunBenchUseCase, format_metric,
13 format_pct,
14};
15use anyhow::Context;
16use perfgate_adapters::{HostProbe, ProcessRunner};
17use perfgate_domain::SignificancePolicy;
18use perfgate_types::{
19 BenchConfigFile, Budget, CHECK_ID_BASELINE, CHECK_ID_BUDGET, CompareReceipt, CompareRef,
20 ConfigFile, ConfigValidationError, FINDING_CODE_BASELINE_MISSING, FINDING_CODE_METRIC_FAIL,
21 FINDING_CODE_METRIC_WARN, FindingData, HostMismatchPolicy, Metric, MetricStatistic,
22 MetricStatus, PerfgateError, PerfgateReport, REPORT_SCHEMA_V1, ReportFinding, ReportSummary,
23 RunReceipt, Severity, ToolInfo, VERDICT_REASON_NO_BASELINE, Verdict, VerdictCounts,
24 VerdictStatus,
25};
26use std::collections::BTreeMap;
27use std::path::PathBuf;
28
29#[derive(Debug, Clone)]
31pub struct CheckRequest {
32 pub config: ConfigFile,
34
35 pub bench_name: String,
37
38 pub out_dir: PathBuf,
40
41 pub baseline: Option<RunReceipt>,
43
44 pub baseline_path: Option<PathBuf>,
46
47 pub require_baseline: bool,
49
50 pub fail_on_warn: bool,
52
53 pub noise_threshold: Option<f64>,
55
56 pub noise_policy: Option<perfgate_types::NoisePolicy>,
58
59 pub tool: ToolInfo,
61
62 pub env: Vec<(String, String)>,
64
65 pub output_cap_bytes: usize,
67
68 pub allow_nonzero: bool,
70
71 pub host_mismatch_policy: HostMismatchPolicy,
73
74 pub significance_alpha: Option<f64>,
76
77 pub significance_min_samples: u32,
79
80 pub require_significance: bool,
82}
83
84#[derive(Debug, Clone)]
86pub struct CheckOutcome {
87 pub run_receipt: RunReceipt,
89
90 pub run_path: PathBuf,
92
93 pub compare_receipt: Option<CompareReceipt>,
95
96 pub compare_path: Option<PathBuf>,
98
99 pub report: PerfgateReport,
101
102 pub report_path: PathBuf,
104
105 pub markdown: String,
107
108 pub markdown_path: PathBuf,
110
111 pub warnings: Vec<String>,
113
114 pub failed: bool,
116
117 pub exit_code: i32,
119}
120
121pub struct CheckUseCase<R: ProcessRunner + Clone, H: HostProbe + Clone, C: Clock + Clone> {
123 runner: R,
124 host_probe: H,
125 clock: C,
126}
127
128impl<R: ProcessRunner + Clone, H: HostProbe + Clone, C: Clock + Clone> CheckUseCase<R, H, C> {
129 pub fn new(runner: R, host_probe: H, clock: C) -> Self {
130 Self {
131 runner,
132 host_probe,
133 clock,
134 }
135 }
136
137 pub fn execute(&self, req: CheckRequest) -> anyhow::Result<CheckOutcome> {
139 let mut warnings = Vec::new();
140
141 let bench_config = req
143 .config
144 .benches
145 .iter()
146 .find(|b| b.name == req.bench_name)
147 .ok_or_else(|| {
148 ConfigValidationError::BenchName(format!(
149 "bench '{}' not found in config",
150 req.bench_name
151 ))
152 })?;
153
154 let run_request = self.build_run_request(bench_config, &req)?;
156
157 let run_usecase = RunBenchUseCase::new(
159 self.runner.clone(),
160 self.host_probe.clone(),
161 self.clock.clone(),
162 req.tool.clone(),
163 );
164 let run_outcome = run_usecase.execute(run_request)?;
165 let run_receipt = run_outcome.receipt;
166
167 let run_path = req.out_dir.join("run.json");
169
170 let report_path = req.out_dir.join("report.json");
172 let (compare_receipt, compare_path, report) = if let Some(baseline) = &req.baseline {
173 let (budgets, metric_statistics) = self.build_budgets(
175 bench_config,
176 &req.config,
177 baseline,
178 &run_receipt,
179 req.noise_threshold,
180 req.noise_policy,
181 )?;
182
183 let compare_req = CompareRequest {
185 baseline: baseline.clone(),
186 current: run_receipt.clone(),
187 budgets,
188 metric_statistics,
189 significance: req
190 .significance_alpha
191 .map(|alpha| {
192 SignificancePolicy::new(
193 alpha,
194 req.significance_min_samples as usize,
195 req.require_significance,
196 )
197 })
198 .transpose()?,
199 baseline_ref: CompareRef {
200 path: req.baseline_path.as_ref().map(|p| p.display().to_string()),
201 run_id: Some(baseline.run.id.clone()),
202 },
203 current_ref: CompareRef {
204 path: Some(run_path.display().to_string()),
205 run_id: Some(run_receipt.run.id.clone()),
206 },
207 tool: req.tool.clone(),
208 host_mismatch_policy: req.host_mismatch_policy,
209 };
210
211 let compare_result = CompareUseCase::execute(compare_req)?;
212
213 if let Some(mismatch) = &compare_result.host_mismatch {
215 for reason in &mismatch.reasons {
216 warnings.push(format!("host mismatch: {}", reason));
217 }
218 }
219
220 let report = build_report(&compare_result.receipt);
222
223 let compare_path = req.out_dir.join("compare.json");
224
225 (Some(compare_result.receipt), Some(compare_path), report)
226 } else {
227 if req.require_baseline {
229 use perfgate_error::IoError;
230 return Err(PerfgateError::Io(IoError::BaselineNotFound {
231 path: format!("bench '{}'", req.bench_name),
232 })
233 .into());
234 }
235 warnings.push(format!(
236 "no baseline found for bench '{}', skipping comparison",
237 req.bench_name
238 ));
239
240 let report = build_no_baseline_report(&run_receipt);
242
243 (None, None, report)
244 };
245
246 let markdown = if let Some(compare) = &compare_receipt {
248 crate::render_markdown(compare)
249 } else {
250 render_no_baseline_markdown(&run_receipt, &warnings)
251 };
252
253 let markdown_path = req.out_dir.join("comment.md");
254
255 let (failed, exit_code) = if let Some(compare) = &compare_receipt {
257 match compare.verdict.status {
258 VerdictStatus::Pass | VerdictStatus::Skip => (false, 0),
259 VerdictStatus::Warn => {
260 if req.fail_on_warn {
261 (true, 3)
262 } else {
263 (false, 0)
264 }
265 }
266 VerdictStatus::Fail => (true, 2),
267 }
268 } else {
269 (false, 0)
271 };
272
273 Ok(CheckOutcome {
274 run_receipt,
275 run_path,
276 compare_receipt,
277 compare_path,
278 report,
279 report_path,
280 markdown,
281 markdown_path,
282 warnings,
283 failed,
284 exit_code,
285 })
286 }
287
288 fn build_run_request(
289 &self,
290 bench: &BenchConfigFile,
291 req: &CheckRequest,
292 ) -> anyhow::Result<RunBenchRequest> {
293 let defaults = &req.config.defaults;
294
295 let repeat = bench.repeat.or(defaults.repeat).unwrap_or(5);
297
298 let warmup = bench.warmup.or(defaults.warmup).unwrap_or(0);
300
301 let timeout = bench
303 .timeout
304 .as_deref()
305 .map(|s| {
306 humantime::parse_duration(s)
307 .with_context(|| format!("invalid timeout '{}' for bench '{}'", s, bench.name))
308 })
309 .transpose()?;
310
311 let cwd = bench.cwd.as_ref().map(PathBuf::from);
313
314 Ok(RunBenchRequest {
315 name: bench.name.clone(),
316 cwd,
317 command: bench.command.clone(),
318 repeat,
319 warmup,
320 work_units: bench.work,
321 timeout,
322 env: req.env.clone(),
323 output_cap_bytes: req.output_cap_bytes,
324 allow_nonzero: req.allow_nonzero,
325 include_hostname_hash: false,
326 })
327 }
328
329 fn build_budgets(
330 &self,
331 bench: &BenchConfigFile,
332 config: &ConfigFile,
333 baseline: &RunReceipt,
334 current: &RunReceipt,
335 cli_noise_threshold: Option<f64>,
336 cli_noise_policy: Option<perfgate_types::NoisePolicy>,
337 ) -> anyhow::Result<(BTreeMap<Metric, Budget>, BTreeMap<Metric, MetricStatistic>)> {
338 let defaults = &config.defaults;
339
340 let global_threshold = defaults.threshold.unwrap_or(0.20);
342 let global_warn_factor = defaults.warn_factor.unwrap_or(0.90);
343
344 let mut candidates = Vec::new();
346 candidates.push(Metric::WallMs);
347 if baseline.stats.cpu_ms.is_some() && current.stats.cpu_ms.is_some() {
348 candidates.push(Metric::CpuMs);
349 }
350 if baseline.stats.page_faults.is_some() && current.stats.page_faults.is_some() {
351 candidates.push(Metric::PageFaults);
352 }
353 if baseline.stats.ctx_switches.is_some() && current.stats.ctx_switches.is_some() {
354 candidates.push(Metric::CtxSwitches);
355 }
356 if baseline.stats.max_rss_kb.is_some() && current.stats.max_rss_kb.is_some() {
357 candidates.push(Metric::MaxRssKb);
358 }
359 if baseline.stats.binary_bytes.is_some() && current.stats.binary_bytes.is_some() {
360 candidates.push(Metric::BinaryBytes);
361 }
362 if baseline.stats.throughput_per_s.is_some() && current.stats.throughput_per_s.is_some() {
363 candidates.push(Metric::ThroughputPerS);
364 }
365
366 let mut budgets = BTreeMap::new();
367 let mut metric_statistics = BTreeMap::new();
368
369 for metric in candidates {
370 let override_opt = bench.budgets.as_ref().and_then(|b| b.get(&metric).cloned());
372
373 let threshold = override_opt
374 .as_ref()
375 .and_then(|o| o.threshold)
376 .unwrap_or(global_threshold);
377
378 let warn_factor = override_opt
379 .as_ref()
380 .and_then(|o| o.warn_factor)
381 .unwrap_or(global_warn_factor);
382
383 let warn_threshold = threshold * warn_factor;
384
385 let noise_threshold = override_opt
386 .as_ref()
387 .and_then(|o| o.noise_threshold)
388 .or(cli_noise_threshold)
389 .or(defaults.noise_threshold);
390
391 let noise_policy = override_opt
392 .as_ref()
393 .and_then(|o| o.noise_policy)
394 .or(cli_noise_policy)
395 .or(defaults.noise_policy)
396 .unwrap_or(perfgate_types::NoisePolicy::Warn);
397
398 let direction = override_opt
399 .as_ref()
400 .and_then(|o| o.direction)
401 .unwrap_or_else(|| metric.default_direction());
402
403 let statistic = override_opt
404 .as_ref()
405 .and_then(|o| o.statistic)
406 .unwrap_or(MetricStatistic::Median);
407
408 budgets.insert(
409 metric,
410 Budget {
411 threshold,
412 warn_threshold,
413 noise_threshold,
414 noise_policy,
415 direction,
416 },
417 );
418
419 metric_statistics.insert(metric, statistic);
420 }
421
422 Ok((budgets, metric_statistics))
423 }
424}
425
426fn build_report(compare: &CompareReceipt) -> PerfgateReport {
428 let mut findings = Vec::new();
429
430 for (metric, delta) in &compare.deltas {
431 let severity = match delta.status {
432 MetricStatus::Pass | MetricStatus::Skip => continue,
433 MetricStatus::Warn => Severity::Warn,
434 MetricStatus::Fail => Severity::Fail,
435 };
436
437 let budget = compare.budgets.get(metric);
438 let (threshold, direction) = budget
439 .map(|b| (b.threshold, b.direction))
440 .unwrap_or((0.20, metric.default_direction()));
441
442 let code = match delta.status {
443 MetricStatus::Warn => FINDING_CODE_METRIC_WARN.to_string(),
444 MetricStatus::Fail => FINDING_CODE_METRIC_FAIL.to_string(),
445 MetricStatus::Pass | MetricStatus::Skip => unreachable!(),
446 };
447
448 let metric_name = format_metric(*metric).to_string();
449 let message = format!(
450 "{} regression: {} (threshold: {:.1}%)",
451 metric_name,
452 format_pct(delta.pct),
453 threshold * 100.0
454 );
455
456 findings.push(ReportFinding {
457 check_id: CHECK_ID_BUDGET.to_string(),
458 code,
459 severity,
460 message,
461 data: Some(FindingData {
462 metric_name,
463 baseline: delta.baseline,
464 current: delta.current,
465 regression_pct: delta.pct * 100.0,
466 threshold,
467 direction,
468 }),
469 });
470 }
471
472 let summary = ReportSummary {
473 pass_count: compare.verdict.counts.pass,
474 warn_count: compare.verdict.counts.warn,
475 fail_count: compare.verdict.counts.fail,
476 skip_count: compare.verdict.counts.skip,
477 total_count: compare.verdict.counts.pass
478 + compare.verdict.counts.warn
479 + compare.verdict.counts.fail
480 + compare.verdict.counts.skip,
481 };
482
483 PerfgateReport {
484 report_type: REPORT_SCHEMA_V1.to_string(),
485 verdict: compare.verdict.clone(),
486 compare: Some(compare.clone()),
487 findings,
488 summary,
489 }
490}
491
492fn build_no_baseline_report(run: &RunReceipt) -> PerfgateReport {
499 let verdict = Verdict {
501 status: VerdictStatus::Warn,
502 counts: VerdictCounts {
503 pass: 0,
504 warn: 1,
505 fail: 0,
506 skip: 0,
507 },
508 reasons: vec![VERDICT_REASON_NO_BASELINE.to_string()],
509 };
510
511 let finding = ReportFinding {
513 check_id: CHECK_ID_BASELINE.to_string(),
514 code: FINDING_CODE_BASELINE_MISSING.to_string(),
515 severity: Severity::Warn,
516 message: format!(
517 "No baseline found for bench '{}'; comparison skipped",
518 run.bench.name
519 ),
520 data: None, };
522
523 PerfgateReport {
524 report_type: REPORT_SCHEMA_V1.to_string(),
525 verdict,
526 compare: None, findings: vec![finding],
528 summary: ReportSummary {
529 pass_count: 0,
530 warn_count: 1,
531 fail_count: 0,
532 skip_count: 0,
533 total_count: 1,
534 },
535 }
536}
537
538fn render_no_baseline_markdown(run: &RunReceipt, warnings: &[String]) -> String {
540 let mut out = String::new();
541
542 out.push_str("## perfgate: no baseline\n\n");
543 out.push_str(&format!("**Bench:** `{}`\n\n", run.bench.name));
544 out.push_str("No baseline found for comparison. This run will establish a new baseline.\n\n");
545
546 out.push_str("### Current Results\n\n");
547 out.push_str("| metric | value |\n");
548 out.push_str("|---|---:|\n");
549 out.push_str(&format!(
550 "| `wall_ms` | {} ms |\n",
551 run.stats.wall_ms.median
552 ));
553
554 if let Some(cpu) = &run.stats.cpu_ms {
555 out.push_str(&format!("| `cpu_ms` | {} ms |\n", cpu.median));
556 }
557
558 if let Some(page_faults) = &run.stats.page_faults {
559 out.push_str(&format!(
560 "| `page_faults` | {} count |\n",
561 page_faults.median
562 ));
563 }
564
565 if let Some(ctx_switches) = &run.stats.ctx_switches {
566 out.push_str(&format!(
567 "| `ctx_switches` | {} count |\n",
568 ctx_switches.median
569 ));
570 }
571
572 if let Some(rss) = &run.stats.max_rss_kb {
573 out.push_str(&format!("| `max_rss_kb` | {} KB |\n", rss.median));
574 }
575
576 if let Some(binary_bytes) = &run.stats.binary_bytes {
577 out.push_str(&format!(
578 "| `binary_bytes` | {} bytes |\n",
579 binary_bytes.median
580 ));
581 }
582
583 if let Some(throughput) = &run.stats.throughput_per_s {
584 out.push_str(&format!(
585 "| `throughput_per_s` | {:.3} /s |\n",
586 throughput.median
587 ));
588 }
589
590 if !warnings.is_empty() {
591 out.push_str("\n**Warnings:**\n");
592 for w in warnings {
593 out.push_str(&format!("- {}\n", w));
594 }
595 }
596
597 out
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use perfgate_adapters::{AdapterError, CommandSpec, HostProbeOptions, RunResult};
604 use perfgate_types::{
605 BaselineServerConfig, BenchConfigFile, BenchMeta, BudgetOverride, COMPARE_SCHEMA_V1,
606 CompareReceipt, DefaultsConfig, Delta, Direction, HostInfo, Metric, RunMeta, Sample, Stats,
607 U64Summary, Verdict, VerdictCounts,
608 };
609 use std::sync::{Arc, Mutex};
610 use std::time::Duration;
611
612 fn make_run_receipt(wall_ms_median: u64) -> RunReceipt {
613 RunReceipt {
614 schema: perfgate_types::RUN_SCHEMA_V1.to_string(),
615 tool: ToolInfo {
616 name: "perfgate".to_string(),
617 version: "0.1.0".to_string(),
618 },
619 run: RunMeta {
620 id: "test-run".to_string(),
621 started_at: "2024-01-01T00:00:00Z".to_string(),
622 ended_at: "2024-01-01T00:01:00Z".to_string(),
623 host: HostInfo {
624 os: "linux".to_string(),
625 arch: "x86_64".to_string(),
626 cpu_count: None,
627 memory_bytes: None,
628 hostname_hash: None,
629 },
630 },
631 bench: BenchMeta {
632 name: "test-bench".to_string(),
633 cwd: None,
634 command: vec!["echo".to_string(), "hello".to_string()],
635 repeat: 5,
636 warmup: 0,
637 work_units: None,
638 timeout_ms: None,
639 },
640 samples: vec![Sample {
641 wall_ms: wall_ms_median,
642 exit_code: 0,
643 warmup: false,
644 timed_out: false,
645 cpu_ms: None,
646 page_faults: None,
647 ctx_switches: None,
648 max_rss_kb: Some(1024),
649 io_read_bytes: None,
650 io_write_bytes: None,
651 network_packets: None,
652 energy_uj: None,
653 binary_bytes: None,
654 stdout: None,
655 stderr: None,
656 }],
657 stats: Stats {
658 wall_ms: U64Summary::new(
659 wall_ms_median,
660 wall_ms_median.saturating_sub(10),
661 wall_ms_median.saturating_add(10),
662 ),
663 cpu_ms: None,
664 page_faults: None,
665 ctx_switches: None,
666 max_rss_kb: Some(U64Summary::new(1024, 1000, 1100)),
667 io_read_bytes: None,
668 io_write_bytes: None,
669 network_packets: None,
670 energy_uj: None,
671 binary_bytes: None,
672 throughput_per_s: None,
673 },
674 }
675 }
676
677 #[derive(Clone)]
678 struct TestRunner {
679 runs: Arc<Mutex<Vec<RunResult>>>,
680 }
681
682 impl TestRunner {
683 fn new(runs: Vec<RunResult>) -> Self {
684 Self {
685 runs: Arc::new(Mutex::new(runs)),
686 }
687 }
688 }
689
690 impl ProcessRunner for TestRunner {
691 fn run(&self, _spec: &CommandSpec) -> Result<RunResult, AdapterError> {
692 let mut runs = self.runs.lock().expect("lock runs");
693 if runs.is_empty() {
694 return Err(AdapterError::Other("no more queued runs".to_string()));
695 }
696 Ok(runs.remove(0))
697 }
698 }
699
700 #[derive(Clone)]
701 struct TestHostProbe {
702 host: HostInfo,
703 }
704
705 impl TestHostProbe {
706 fn new(host: HostInfo) -> Self {
707 Self { host }
708 }
709 }
710
711 impl HostProbe for TestHostProbe {
712 fn probe(&self, _options: &HostProbeOptions) -> HostInfo {
713 self.host.clone()
714 }
715 }
716
717 #[derive(Clone)]
718 struct TestClock {
719 now: String,
720 }
721
722 impl TestClock {
723 fn new(now: &str) -> Self {
724 Self {
725 now: now.to_string(),
726 }
727 }
728 }
729
730 impl Clock for TestClock {
731 fn now_rfc3339(&self) -> String {
732 self.now.clone()
733 }
734 }
735
736 fn run_result(wall_ms: u64, exit_code: i32, timed_out: bool) -> RunResult {
737 RunResult {
738 wall_ms,
739 exit_code,
740 timed_out,
741 cpu_ms: None,
742 page_faults: None,
743 ctx_switches: None,
744 max_rss_kb: None,
745 io_read_bytes: None,
746 io_write_bytes: None,
747 network_packets: None,
748 energy_uj: None,
749 binary_bytes: None,
750 stdout: Vec::new(),
751 stderr: Vec::new(),
752 }
753 }
754
755 fn make_baseline_receipt(wall_ms: u64, host: HostInfo, max_rss_kb: Option<u64>) -> RunReceipt {
756 RunReceipt {
757 schema: perfgate_types::RUN_SCHEMA_V1.to_string(),
758 tool: ToolInfo {
759 name: "perfgate".to_string(),
760 version: "0.1.0".to_string(),
761 },
762 run: RunMeta {
763 id: "baseline-id".to_string(),
764 started_at: "2024-01-01T00:00:00Z".to_string(),
765 ended_at: "2024-01-01T00:00:01Z".to_string(),
766 host,
767 },
768 bench: BenchMeta {
769 name: "bench".to_string(),
770 cwd: None,
771 command: vec!["echo".to_string(), "hello".to_string()],
772 repeat: 1,
773 warmup: 0,
774 work_units: None,
775 timeout_ms: None,
776 },
777 samples: Vec::new(),
778 stats: Stats {
779 wall_ms: U64Summary::new(wall_ms, wall_ms, wall_ms),
780 cpu_ms: None,
781 page_faults: None,
782 ctx_switches: None,
783 max_rss_kb: max_rss_kb.map(|v| U64Summary::new(v, v, v)),
784 io_read_bytes: None,
785 io_write_bytes: None,
786 network_packets: None,
787 energy_uj: None,
788 binary_bytes: None,
789 throughput_per_s: None,
790 },
791 }
792 }
793
794 fn make_check_request(
795 config: ConfigFile,
796 baseline: Option<RunReceipt>,
797 host_mismatch_policy: HostMismatchPolicy,
798 fail_on_warn: bool,
799 ) -> CheckRequest {
800 CheckRequest {
801 noise_threshold: None,
802 noise_policy: None,
803 config,
804 bench_name: "bench".to_string(),
805 out_dir: PathBuf::from("out"),
806 baseline,
807 baseline_path: None,
808 require_baseline: false,
809 fail_on_warn,
810 tool: ToolInfo {
811 name: "perfgate".to_string(),
812 version: "0.1.0".to_string(),
813 },
814 env: vec![],
815 output_cap_bytes: 1024,
816 allow_nonzero: false,
817 host_mismatch_policy,
818 significance_alpha: None,
819 significance_min_samples: 8,
820 require_significance: false,
821 }
822 }
823
824 #[test]
825 fn test_build_report_from_compare() {
826 let mut budgets = BTreeMap::new();
827 budgets.insert(Metric::WallMs, Budget::new(0.20, 0.18, Direction::Lower));
828
829 let mut deltas = BTreeMap::new();
830 deltas.insert(
831 Metric::WallMs,
832 Delta {
833 baseline: 1000.0,
834 current: 1250.0,
835 ratio: 1.25,
836 pct: 0.25,
837 regression: 0.25,
838 cv: None,
839 noise_threshold: None,
840 statistic: MetricStatistic::Median,
841 significance: None,
842 status: MetricStatus::Fail,
843 },
844 );
845
846 let compare = CompareReceipt {
847 schema: COMPARE_SCHEMA_V1.to_string(),
848 tool: ToolInfo {
849 name: "perfgate".to_string(),
850 version: "0.1.0".to_string(),
851 },
852 bench: BenchMeta {
853 name: "test-bench".to_string(),
854 cwd: None,
855 command: vec!["echo".to_string()],
856 repeat: 5,
857 warmup: 0,
858 work_units: None,
859 timeout_ms: None,
860 },
861 baseline_ref: CompareRef {
862 path: Some("baseline.json".to_string()),
863 run_id: Some("baseline-id".to_string()),
864 },
865 current_ref: CompareRef {
866 path: Some("current.json".to_string()),
867 run_id: Some("current-id".to_string()),
868 },
869 budgets,
870 deltas,
871 verdict: Verdict {
872 status: VerdictStatus::Fail,
873 counts: VerdictCounts {
874 pass: 0,
875 warn: 0,
876 fail: 1,
877 skip: 0,
878 },
879 reasons: vec!["wall_ms_fail".to_string()],
880 },
881 };
882
883 let report = build_report(&compare);
884
885 assert_eq!(report.report_type, REPORT_SCHEMA_V1);
886 assert_eq!(report.verdict.status, VerdictStatus::Fail);
887 assert_eq!(report.findings.len(), 1);
888 assert_eq!(report.findings[0].severity, Severity::Fail);
889 assert_eq!(report.findings[0].check_id, "perf.budget");
890 assert_eq!(report.summary.fail_count, 1);
891 assert_eq!(report.summary.total_count, 1);
892 }
893
894 #[test]
895 fn test_render_no_baseline_markdown() {
896 let run = make_run_receipt(1000);
897 let warnings = vec!["no baseline found".to_string()];
898
899 let md = render_no_baseline_markdown(&run, &warnings);
900
901 assert!(md.contains("perfgate: no baseline"));
902 assert!(md.contains("test-bench"));
903 assert!(md.contains("wall_ms"));
904 assert!(md.contains("no baseline found"));
905 }
906
907 #[test]
908 fn test_build_no_baseline_report() {
909 let run = make_run_receipt(1000);
910
911 let report = build_no_baseline_report(&run);
912
913 assert_eq!(report.report_type, REPORT_SCHEMA_V1);
915
916 assert_eq!(report.verdict.status, VerdictStatus::Warn);
918 assert_eq!(report.verdict.counts.pass, 0);
919 assert_eq!(report.verdict.counts.warn, 1);
920 assert_eq!(report.verdict.counts.fail, 0);
921 assert_eq!(report.verdict.reasons.len(), 1);
922 assert_eq!(report.verdict.reasons[0], "no_baseline");
923
924 assert_eq!(report.findings.len(), 1);
926 let finding = &report.findings[0];
927 assert_eq!(finding.check_id, "perf.baseline");
928 assert_eq!(finding.code, "missing");
929 assert_eq!(finding.severity, Severity::Warn);
930 assert!(finding.message.contains("No baseline found"));
931 assert!(finding.message.contains("test-bench"));
932 assert!(finding.data.is_none()); assert_eq!(report.summary.pass_count, 0);
936 assert_eq!(report.summary.warn_count, 1);
937 assert_eq!(report.summary.fail_count, 0);
938 assert_eq!(report.summary.total_count, 1);
939
940 assert!(report.compare.is_none());
942 }
943
944 #[test]
945 fn build_run_request_resolves_defaults_and_timeout() {
946 let bench = BenchConfigFile {
947 name: "bench".to_string(),
948 cwd: Some("some/dir".to_string()),
949 work: Some(42),
950 timeout: Some("2s".to_string()),
951 command: vec!["echo".to_string(), "ok".to_string()],
952 repeat: None,
953 warmup: None,
954 metrics: None,
955 budgets: None,
956 };
957
958 let config = ConfigFile {
959 defaults: DefaultsConfig {
960 noise_threshold: None,
961 noise_policy: None,
962 repeat: Some(7),
963 warmup: Some(2),
964 threshold: None,
965 warn_factor: None,
966 out_dir: None,
967 baseline_dir: None,
968 baseline_pattern: None,
969 markdown_template: None,
970 },
971 baseline_server: BaselineServerConfig::default(),
972 benches: vec![bench.clone()],
973 };
974
975 let req = CheckRequest {
976 noise_threshold: None,
977 noise_policy: None,
978 config: config.clone(),
979 bench_name: "bench".to_string(),
980 out_dir: PathBuf::from("out"),
981 baseline: None,
982 baseline_path: None,
983 require_baseline: false,
984 fail_on_warn: false,
985 tool: ToolInfo {
986 name: "perfgate".to_string(),
987 version: "0.1.0".to_string(),
988 },
989 env: vec![("K".to_string(), "V".to_string())],
990 output_cap_bytes: 512,
991 allow_nonzero: true,
992 host_mismatch_policy: HostMismatchPolicy::Warn,
993 significance_alpha: None,
994 significance_min_samples: 8,
995 require_significance: false,
996 };
997
998 let usecase = CheckUseCase::new(
999 TestRunner::new(Vec::new()),
1000 TestHostProbe::new(HostInfo {
1001 os: "linux".to_string(),
1002 arch: "x86_64".to_string(),
1003 cpu_count: None,
1004 memory_bytes: None,
1005 hostname_hash: None,
1006 }),
1007 TestClock::new("2024-01-01T00:00:00Z"),
1008 );
1009
1010 let run_req = usecase
1011 .build_run_request(&bench, &req)
1012 .expect("build run request");
1013 assert_eq!(run_req.repeat, 7);
1014 assert_eq!(run_req.warmup, 2);
1015 assert_eq!(run_req.work_units, Some(42));
1016 assert_eq!(run_req.timeout, Some(Duration::from_secs(2)));
1017 assert_eq!(run_req.output_cap_bytes, 512);
1018 assert_eq!(run_req.env.len(), 1);
1019 }
1020
1021 #[test]
1022 fn build_run_request_rejects_invalid_timeout() {
1023 let bench = BenchConfigFile {
1024 name: "bench".to_string(),
1025 cwd: None,
1026 work: None,
1027 timeout: Some("not-a-duration".to_string()),
1028 command: vec!["echo".to_string()],
1029 repeat: None,
1030 warmup: None,
1031 metrics: None,
1032 budgets: None,
1033 };
1034 let config = ConfigFile::default();
1035 let req = make_check_request(config, None, HostMismatchPolicy::Warn, false);
1036
1037 let usecase = CheckUseCase::new(
1038 TestRunner::new(Vec::new()),
1039 TestHostProbe::new(HostInfo {
1040 os: "linux".to_string(),
1041 arch: "x86_64".to_string(),
1042 cpu_count: None,
1043 memory_bytes: None,
1044 hostname_hash: None,
1045 }),
1046 TestClock::new("2024-01-01T00:00:00Z"),
1047 );
1048
1049 let err = usecase.build_run_request(&bench, &req).unwrap_err();
1050 assert!(
1051 err.to_string().contains("invalid timeout"),
1052 "unexpected error: {}",
1053 err
1054 );
1055 }
1056
1057 #[test]
1058 fn build_budgets_applies_overrides_and_defaults() {
1059 let mut overrides = BTreeMap::new();
1060 overrides.insert(
1061 Metric::WallMs,
1062 BudgetOverride {
1063 noise_threshold: None,
1064 noise_policy: None,
1065 threshold: Some(0.3),
1066 direction: Some(Direction::Higher),
1067 warn_factor: Some(0.8),
1068 statistic: Some(MetricStatistic::P95),
1069 },
1070 );
1071
1072 let bench = BenchConfigFile {
1073 name: "bench".to_string(),
1074 cwd: None,
1075 work: None,
1076 timeout: None,
1077 command: vec!["echo".to_string()],
1078 repeat: None,
1079 warmup: None,
1080 metrics: None,
1081 budgets: Some(overrides),
1082 };
1083
1084 let config = ConfigFile {
1085 defaults: DefaultsConfig {
1086 noise_threshold: None,
1087 noise_policy: None,
1088 repeat: None,
1089 warmup: None,
1090 threshold: Some(0.2),
1091 warn_factor: Some(0.5),
1092 out_dir: None,
1093 baseline_dir: None,
1094 baseline_pattern: None,
1095 markdown_template: None,
1096 },
1097 baseline_server: BaselineServerConfig::default(),
1098 benches: vec![bench.clone()],
1099 };
1100
1101 let baseline = make_baseline_receipt(
1102 100,
1103 HostInfo {
1104 os: "linux".to_string(),
1105 arch: "x86_64".to_string(),
1106 cpu_count: None,
1107 memory_bytes: None,
1108 hostname_hash: None,
1109 },
1110 Some(1024),
1111 );
1112 let current = make_baseline_receipt(
1113 110,
1114 HostInfo {
1115 os: "linux".to_string(),
1116 arch: "x86_64".to_string(),
1117 cpu_count: None,
1118 memory_bytes: None,
1119 hostname_hash: None,
1120 },
1121 Some(2048),
1122 );
1123
1124 let usecase = CheckUseCase::new(
1125 TestRunner::new(Vec::new()),
1126 TestHostProbe::new(HostInfo {
1127 os: "linux".to_string(),
1128 arch: "x86_64".to_string(),
1129 cpu_count: None,
1130 memory_bytes: None,
1131 hostname_hash: None,
1132 }),
1133 TestClock::new("2024-01-01T00:00:00Z"),
1134 );
1135
1136 let (budgets, statistics) = usecase
1137 .build_budgets(&bench, &config, &baseline, ¤t, None, None)
1138 .expect("build budgets");
1139
1140 let wall = budgets.get(&Metric::WallMs).expect("wall budget");
1141 assert!((wall.threshold - 0.3).abs() < f64::EPSILON);
1142 assert!((wall.warn_threshold - 0.24).abs() < f64::EPSILON);
1143 assert_eq!(wall.direction, Direction::Higher);
1144
1145 let max_rss = budgets.get(&Metric::MaxRssKb).expect("max_rss budget");
1146 assert!((max_rss.threshold - 0.2).abs() < f64::EPSILON);
1147 assert!((max_rss.warn_threshold - 0.1).abs() < f64::EPSILON);
1148 assert_eq!(max_rss.direction, Direction::Lower);
1149
1150 assert_eq!(statistics.get(&Metric::WallMs), Some(&MetricStatistic::P95));
1151 assert_eq!(
1152 statistics.get(&Metric::MaxRssKb),
1153 Some(&MetricStatistic::Median)
1154 );
1155 }
1156
1157 #[test]
1158 fn execute_no_baseline_builds_warn_report() {
1159 let bench = BenchConfigFile {
1160 name: "bench".to_string(),
1161 cwd: None,
1162 work: None,
1163 timeout: None,
1164 command: vec!["echo".to_string(), "ok".to_string()],
1165 repeat: Some(1),
1166 warmup: Some(0),
1167 metrics: None,
1168 budgets: None,
1169 };
1170 let config = ConfigFile {
1171 defaults: DefaultsConfig::default(),
1172 baseline_server: BaselineServerConfig::default(),
1173 benches: vec![bench],
1174 };
1175
1176 let runner = TestRunner::new(vec![run_result(100, 0, false)]);
1177 let host_probe = TestHostProbe::new(HostInfo {
1178 os: "linux".to_string(),
1179 arch: "x86_64".to_string(),
1180 cpu_count: None,
1181 memory_bytes: None,
1182 hostname_hash: None,
1183 });
1184 let clock = TestClock::new("2024-01-01T00:00:00Z");
1185 let usecase = CheckUseCase::new(runner, host_probe, clock);
1186
1187 let outcome = usecase
1188 .execute(make_check_request(
1189 config,
1190 None,
1191 HostMismatchPolicy::Warn,
1192 false,
1193 ))
1194 .expect("check should succeed");
1195
1196 assert!(outcome.compare_receipt.is_none());
1197 assert_eq!(outcome.report.verdict.status, VerdictStatus::Warn);
1198 assert!(
1199 outcome
1200 .warnings
1201 .iter()
1202 .any(|w| w.contains("no baseline found")),
1203 "expected no-baseline warning"
1204 );
1205 assert!(!outcome.failed);
1206 assert_eq!(outcome.exit_code, 0);
1207 }
1208
1209 #[test]
1210 fn execute_with_baseline_emits_host_mismatch_warning() {
1211 let bench = BenchConfigFile {
1212 name: "bench".to_string(),
1213 cwd: None,
1214 work: None,
1215 timeout: None,
1216 command: vec!["echo".to_string(), "ok".to_string()],
1217 repeat: Some(1),
1218 warmup: Some(0),
1219 metrics: None,
1220 budgets: None,
1221 };
1222 let config = ConfigFile {
1223 defaults: DefaultsConfig::default(),
1224 baseline_server: BaselineServerConfig::default(),
1225 benches: vec![bench],
1226 };
1227
1228 let baseline = make_baseline_receipt(
1229 100,
1230 HostInfo {
1231 os: "linux".to_string(),
1232 arch: "x86_64".to_string(),
1233 cpu_count: Some(4),
1234 memory_bytes: None,
1235 hostname_hash: None,
1236 },
1237 None,
1238 );
1239
1240 let runner = TestRunner::new(vec![run_result(100, 0, false)]);
1241 let host_probe = TestHostProbe::new(HostInfo {
1242 os: "windows".to_string(),
1243 arch: "x86_64".to_string(),
1244 cpu_count: Some(4),
1245 memory_bytes: None,
1246 hostname_hash: None,
1247 });
1248 let clock = TestClock::new("2024-01-01T00:00:00Z");
1249 let usecase = CheckUseCase::new(runner, host_probe, clock);
1250
1251 let outcome = usecase
1252 .execute(make_check_request(
1253 config,
1254 Some(baseline),
1255 HostMismatchPolicy::Warn,
1256 false,
1257 ))
1258 .expect("check should succeed");
1259
1260 assert!(outcome.compare_receipt.is_some());
1261 assert!(
1262 outcome.warnings.iter().any(|w| w.contains("host mismatch")),
1263 "expected host mismatch warning"
1264 );
1265 }
1266
1267 #[test]
1268 fn execute_fail_on_warn_sets_exit_code_3() {
1269 let bench = BenchConfigFile {
1270 name: "bench".to_string(),
1271 cwd: None,
1272 work: None,
1273 timeout: None,
1274 command: vec!["echo".to_string(), "ok".to_string()],
1275 repeat: Some(1),
1276 warmup: Some(0),
1277 metrics: None,
1278 budgets: None,
1279 };
1280 let config = ConfigFile {
1281 defaults: DefaultsConfig {
1282 noise_threshold: None,
1283 noise_policy: None,
1284 repeat: None,
1285 warmup: None,
1286 threshold: Some(0.2),
1287 warn_factor: Some(0.5),
1288 out_dir: None,
1289 baseline_dir: None,
1290 baseline_pattern: None,
1291 markdown_template: None,
1292 },
1293 baseline_server: BaselineServerConfig::default(),
1294 benches: vec![bench],
1295 };
1296
1297 let baseline = make_baseline_receipt(
1298 100,
1299 HostInfo {
1300 os: "linux".to_string(),
1301 arch: "x86_64".to_string(),
1302 cpu_count: None,
1303 memory_bytes: None,
1304 hostname_hash: None,
1305 },
1306 None,
1307 );
1308
1309 let runner = TestRunner::new(vec![run_result(115, 0, false)]);
1310 let host_probe = TestHostProbe::new(HostInfo {
1311 os: "linux".to_string(),
1312 arch: "x86_64".to_string(),
1313 cpu_count: None,
1314 memory_bytes: None,
1315 hostname_hash: None,
1316 });
1317 let clock = TestClock::new("2024-01-01T00:00:00Z");
1318 let usecase = CheckUseCase::new(runner, host_probe, clock);
1319
1320 let outcome = usecase
1321 .execute(make_check_request(
1322 config,
1323 Some(baseline),
1324 HostMismatchPolicy::Warn,
1325 true,
1326 ))
1327 .expect("check should succeed");
1328
1329 assert!(outcome.failed);
1330 assert_eq!(outcome.exit_code, 3);
1331 }
1332
1333 #[test]
1334 fn execute_require_baseline_without_baseline_returns_error() {
1335 let bench = BenchConfigFile {
1336 name: "bench".to_string(),
1337 cwd: None,
1338 work: None,
1339 timeout: None,
1340 command: vec!["echo".to_string(), "ok".to_string()],
1341 repeat: Some(1),
1342 warmup: Some(0),
1343 metrics: None,
1344 budgets: None,
1345 };
1346 let config = ConfigFile {
1347 defaults: DefaultsConfig::default(),
1348 baseline_server: BaselineServerConfig::default(),
1349 benches: vec![bench],
1350 };
1351
1352 let runner = TestRunner::new(vec![run_result(100, 0, false)]);
1353 let host_probe = TestHostProbe::new(HostInfo {
1354 os: "linux".to_string(),
1355 arch: "x86_64".to_string(),
1356 cpu_count: None,
1357 memory_bytes: None,
1358 hostname_hash: None,
1359 });
1360 let clock = TestClock::new("2024-01-01T00:00:00Z");
1361 let usecase = CheckUseCase::new(runner, host_probe, clock);
1362
1363 let mut req = make_check_request(config, None, HostMismatchPolicy::Warn, false);
1364 req.require_baseline = true;
1365
1366 let err = usecase.execute(req).unwrap_err();
1367 assert!(
1368 err.to_string().contains("baseline not found"),
1369 "expected baseline not found error, got: {}",
1370 err
1371 );
1372 }
1373
1374 #[test]
1375 fn execute_bench_not_found_returns_error() {
1376 let config = ConfigFile {
1377 defaults: DefaultsConfig::default(),
1378 baseline_server: BaselineServerConfig::default(),
1379 benches: vec![],
1380 };
1381
1382 let runner = TestRunner::new(vec![]);
1383 let host_probe = TestHostProbe::new(HostInfo {
1384 os: "linux".to_string(),
1385 arch: "x86_64".to_string(),
1386 cpu_count: None,
1387 memory_bytes: None,
1388 hostname_hash: None,
1389 });
1390 let clock = TestClock::new("2024-01-01T00:00:00Z");
1391 let usecase = CheckUseCase::new(runner, host_probe, clock);
1392
1393 let req = make_check_request(config, None, HostMismatchPolicy::Warn, false);
1394 let err = usecase.execute(req).unwrap_err();
1395 assert!(
1396 err.to_string().contains("not found"),
1397 "expected bench not found error, got: {}",
1398 err
1399 );
1400 }
1401
1402 #[test]
1403 fn execute_with_baseline_pass_produces_exit_0() {
1404 let bench = BenchConfigFile {
1405 name: "bench".to_string(),
1406 cwd: None,
1407 work: None,
1408 timeout: None,
1409 command: vec!["echo".to_string(), "ok".to_string()],
1410 repeat: Some(1),
1411 warmup: Some(0),
1412 metrics: None,
1413 budgets: None,
1414 };
1415 let config = ConfigFile {
1416 defaults: DefaultsConfig {
1417 noise_threshold: None,
1418 noise_policy: None,
1419 repeat: None,
1420 warmup: None,
1421 threshold: Some(0.5),
1422 warn_factor: Some(0.9),
1423 out_dir: None,
1424 baseline_dir: None,
1425 baseline_pattern: None,
1426 markdown_template: None,
1427 },
1428 baseline_server: BaselineServerConfig::default(),
1429 benches: vec![bench],
1430 };
1431
1432 let baseline = make_baseline_receipt(
1433 100,
1434 HostInfo {
1435 os: "linux".to_string(),
1436 arch: "x86_64".to_string(),
1437 cpu_count: None,
1438 memory_bytes: None,
1439 hostname_hash: None,
1440 },
1441 None,
1442 );
1443
1444 let runner = TestRunner::new(vec![run_result(100, 0, false)]);
1446 let host_probe = TestHostProbe::new(HostInfo {
1447 os: "linux".to_string(),
1448 arch: "x86_64".to_string(),
1449 cpu_count: None,
1450 memory_bytes: None,
1451 hostname_hash: None,
1452 });
1453 let clock = TestClock::new("2024-01-01T00:00:00Z");
1454 let usecase = CheckUseCase::new(runner, host_probe, clock);
1455
1456 let outcome = usecase
1457 .execute(make_check_request(
1458 config,
1459 Some(baseline),
1460 HostMismatchPolicy::Warn,
1461 false,
1462 ))
1463 .expect("check should succeed");
1464
1465 assert!(outcome.compare_receipt.is_some());
1466 assert!(!outcome.failed);
1467 assert_eq!(outcome.exit_code, 0);
1468 assert_eq!(
1469 outcome.compare_receipt.as_ref().unwrap().verdict.status,
1470 VerdictStatus::Pass
1471 );
1472 }
1473}