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