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    /// Suggestion to use paired mode due to high noise (CV > 0.30).
121    pub suggest_paired: bool,
122}
123
124/// Use case for running a config-driven check.
125pub 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    /// Execute the check workflow.
141    pub fn execute(&self, req: CheckRequest) -> anyhow::Result<CheckOutcome> {
142        let mut warnings = Vec::new();
143
144        // 1. Find the bench config by name
145        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        // 2. Build run request from config
158        let run_request = self.build_run_request(bench_config, &req)?;
159
160        // 3. Run the benchmark
161        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        // 4. Write run receipt
171        let run_path = req.out_dir.join("run.json");
172
173        // 5. Handle baseline
174        let report_path = req.out_dir.join("report.json");
175        let (compare_receipt, compare_path, report) = if let Some(baseline) = &req.baseline {
176            // Build budgets from config
177            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            // Compare
187            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            // Add host mismatch warnings if detected (for Warn policy)
217            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            // Build report
224            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            // No baseline
231            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            // Build a no-baseline report for cockpit integration
244            let report = build_no_baseline_report(&run_receipt);
245
246            (None, None, report)
247        };
248
249        // 6. Generate markdown
250        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        // 7. Determine exit code
259        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            // No baseline - pass by default (unless require_baseline was set, which already bailed)
273            (false, 0)
274        };
275
276        // 8. Check for high CV and suggest paired mode
277        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        // Resolve repeat: bench > defaults > 5
309        let repeat = bench.repeat.or(defaults.repeat).unwrap_or(5);
310
311        // Resolve warmup: bench > defaults > 0
312        let warmup = bench.warmup.or(defaults.warmup).unwrap_or(0);
313
314        // Parse timeout if present
315        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        // Resolve cwd
325        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        // Global defaults
354        let global_threshold = defaults.threshold.unwrap_or(0.20);
355        let global_warn_factor = defaults.warn_factor.unwrap_or(0.90);
356
357        // Determine candidate metrics: those present in both baseline+current
358        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            // Check for per-bench budget override
384            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
439/// Build a PerfgateReport from a CompareReceipt.
440fn 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
506/// Build a PerfgateReport for the case when there is no baseline.
507///
508/// Returns a report with Warn status (not Pass) to indicate that while
509/// the check is non-blocking by default, no actual performance evaluation
510/// occurred. The cockpit can highlight this as "baseline missing" rather
511/// than incorrectly showing "pass".
512fn build_no_baseline_report(run: &RunReceipt) -> PerfgateReport {
513    // Warn verdict: the sensor ran but no comparison was possible
514    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    // Single finding for the baseline-missing condition
526    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, // No metric data for structural findings
535    };
536
537    PerfgateReport {
538        report_type: REPORT_SCHEMA_V1.to_string(),
539        verdict,
540        compare: None, // No synthetic compare receipt
541        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
553/// Detect high coefficient of variation in run receipt wall_ms.
554///
555/// Returns true if the wall_ms CV exceeds 0.30 (30%), which indicates
556/// the benchmark environment is noisy and paired mode would give better results.
557fn detect_high_cv(run: &RunReceipt) -> bool {
558    run.stats.wall_ms.cv().map(|cv| cv > 0.30).unwrap_or(false)
559}
560
561/// Render markdown for the case when there is no baseline.
562fn 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        // Verify report structure
937        assert_eq!(report.report_type, REPORT_SCHEMA_V1);
938
939        // Verify verdict is Warn (not Pass) - baseline missing is not "green"
940        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        // Verify single finding for baseline-missing condition
948        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()); // No metric data for structural findings
956
957        // Verify summary reflects the warning
958        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        // Verify no compare receipt (no synthetic comparison)
964        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, &current, 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        // Current is same as baseline → pass
1484        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}