Skip to main content

perfgate_app/
check.rs

1//! CheckUseCase - Config-driven one-command workflow.
2//!
3//! This module implements the `check` command which:
4//! 1. Loads a config file
5//! 2. Finds a bench by name
6//! 3. Runs the bench
7//! 4. Loads baseline (if exists)
8//! 5. Compares results
9//! 6. Generates all artifacts (run.json, compare.json, report.json, comment.md)
10
11use 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/// Request for the check use case.
30#[derive(Debug, Clone)]
31pub struct CheckRequest {
32    /// The loaded configuration file.
33    pub config: ConfigFile,
34
35    /// Name of the bench to run.
36    pub bench_name: String,
37
38    /// Output directory for artifacts.
39    pub out_dir: PathBuf,
40
41    /// Optional baseline receipt (already loaded).
42    pub baseline: Option<RunReceipt>,
43
44    /// Path to the baseline file (for reference in compare receipt).
45    pub baseline_path: Option<PathBuf>,
46
47    /// If true, fail if baseline is missing.
48    pub require_baseline: bool,
49
50    /// If true, treat warn verdict as failure.
51    pub fail_on_warn: bool,
52
53    /// Optional noise threshold (coefficient of variation).
54    pub noise_threshold: Option<f64>,
55
56    /// Optional noise policy.
57    pub noise_policy: Option<perfgate_types::NoisePolicy>,
58
59    /// Tool info for receipts.
60    pub tool: ToolInfo,
61
62    /// Environment variables for the benchmark.
63    pub env: Vec<(String, String)>,
64
65    /// Max bytes captured from stdout/stderr per run.
66    pub output_cap_bytes: usize,
67
68    /// If true, do not treat nonzero exit codes as a tool error.
69    pub allow_nonzero: bool,
70
71    /// Policy for handling host mismatches when comparing against baseline.
72    pub host_mismatch_policy: HostMismatchPolicy,
73
74    /// Optional p-value threshold for significance analysis.
75    pub significance_alpha: Option<f64>,
76
77    /// Minimum samples per side before significance is computed.
78    pub significance_min_samples: u32,
79
80    /// Require significance to escalate warn/fail statuses.
81    pub require_significance: bool,
82}
83
84/// Outcome of the check use case.
85#[derive(Debug, Clone)]
86pub struct CheckOutcome {
87    /// The run receipt produced.
88    pub run_receipt: RunReceipt,
89
90    /// Path where run receipt was written.
91    pub run_path: PathBuf,
92
93    /// The compare receipt (None if no baseline).
94    pub compare_receipt: Option<CompareReceipt>,
95
96    /// Path where compare receipt was written (None if no baseline).
97    pub compare_path: Option<PathBuf>,
98
99    /// The report (always present for cockpit integration).
100    pub report: PerfgateReport,
101
102    /// Path where report was written.
103    pub report_path: PathBuf,
104
105    /// The markdown content.
106    pub markdown: String,
107
108    /// Path where markdown was written.
109    pub markdown_path: PathBuf,
110
111    /// Warnings generated during the check.
112    pub warnings: Vec<String>,
113
114    /// True if the check failed (based on verdict and flags).
115    pub failed: bool,
116
117    /// Exit code to use (0=pass, 2=fail, 3=warn with fail-on-warn).
118    pub exit_code: i32,
119}
120
121/// Use case for running a config-driven check.
122pub 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    /// Execute the check workflow.
138    pub fn execute(&self, req: CheckRequest) -> anyhow::Result<CheckOutcome> {
139        let mut warnings = Vec::new();
140
141        // 1. Find the bench config by name
142        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        // 2. Build run request from config
155        let run_request = self.build_run_request(bench_config, &req)?;
156
157        // 3. Run the benchmark
158        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        // 4. Write run receipt
168        let run_path = req.out_dir.join("run.json");
169
170        // 5. Handle baseline
171        let report_path = req.out_dir.join("report.json");
172        let (compare_receipt, compare_path, report) = if let Some(baseline) = &req.baseline {
173            // Build budgets from config
174            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            // Compare
184            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            // Add host mismatch warnings if detected (for Warn policy)
214            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            // Build report
221            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            // No baseline
228            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            // Build a no-baseline report for cockpit integration
241            let report = build_no_baseline_report(&run_receipt);
242
243            (None, None, report)
244        };
245
246        // 6. Generate markdown
247        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        // 7. Determine exit code
256        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            // No baseline - pass by default (unless require_baseline was set, which already bailed)
270            (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        // Resolve repeat: bench > defaults > 5
296        let repeat = bench.repeat.or(defaults.repeat).unwrap_or(5);
297
298        // Resolve warmup: bench > defaults > 0
299        let warmup = bench.warmup.or(defaults.warmup).unwrap_or(0);
300
301        // Parse timeout if present
302        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        // Resolve cwd
312        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        // Global defaults
341        let global_threshold = defaults.threshold.unwrap_or(0.20);
342        let global_warn_factor = defaults.warn_factor.unwrap_or(0.90);
343
344        // Determine candidate metrics: those present in both baseline+current
345        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            // Check for per-bench budget override
371            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
426/// Build a PerfgateReport from a CompareReceipt.
427fn 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
492/// Build a PerfgateReport for the case when there is no baseline.
493///
494/// Returns a report with Warn status (not Pass) to indicate that while
495/// the check is non-blocking by default, no actual performance evaluation
496/// occurred. The cockpit can highlight this as "baseline missing" rather
497/// than incorrectly showing "pass".
498fn build_no_baseline_report(run: &RunReceipt) -> PerfgateReport {
499    // Warn verdict: the sensor ran but no comparison was possible
500    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    // Single finding for the baseline-missing condition
512    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, // No metric data for structural findings
521    };
522
523    PerfgateReport {
524        report_type: REPORT_SCHEMA_V1.to_string(),
525        verdict,
526        compare: None, // No synthetic compare receipt
527        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
538/// Render markdown for the case when there is no baseline.
539fn 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        // Verify report structure
914        assert_eq!(report.report_type, REPORT_SCHEMA_V1);
915
916        // Verify verdict is Warn (not Pass) - baseline missing is not "green"
917        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        // Verify single finding for baseline-missing condition
925        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()); // No metric data for structural findings
933
934        // Verify summary reflects the warning
935        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        // Verify no compare receipt (no synthetic comparison)
941        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, &current, 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        // Current is same as baseline → pass
1445        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}