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