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