1pub use perfgate_sensor::{
11 BenchOutcome, SensorReportBuilder, default_engine_capability, sensor_fingerprint,
12};
13
14use crate::{CheckRequest, CheckUseCase, Clock};
15use perfgate_adapters::AdapterError;
16use perfgate_adapters::{HostProbe, ProcessRunner};
17use perfgate_types::{
18 BASELINE_REASON_NO_BASELINE, ConfigFile, ConfigValidationError, ERROR_KIND_EXEC, ERROR_KIND_IO,
19 ERROR_KIND_PARSE, HostMismatchPolicy, MAX_FINDINGS_DEFAULT, PerfgateError, RunReceipt,
20 STAGE_BASELINE_RESOLVE, STAGE_CONFIG_PARSE, STAGE_RUN_COMMAND, STAGE_WRITE_ARTIFACTS,
21 SensorReport, ToolInfo, validate_bench_name,
22};
23
24#[derive(Debug, Clone)]
26pub struct SensorCheckOptions {
27 pub require_baseline: bool,
28 pub fail_on_warn: bool,
29 pub env: Vec<(String, String)>,
30 pub output_cap_bytes: usize,
31 pub allow_nonzero: bool,
32 pub host_mismatch_policy: HostMismatchPolicy,
33 pub max_findings: Option<usize>,
34}
35
36impl Default for SensorCheckOptions {
37 fn default() -> Self {
38 Self {
39 require_baseline: false,
40 fail_on_warn: false,
41 env: Vec::new(),
42 output_cap_bytes: 8192,
43 allow_nonzero: false,
44 host_mismatch_policy: HostMismatchPolicy::Warn,
45 max_findings: Some(MAX_FINDINGS_DEFAULT),
46 }
47 }
48}
49
50#[allow(clippy::too_many_arguments)]
58pub fn run_sensor_check<R, H, C>(
59 runner: &R,
60 host_probe: &H,
61 clock: &C,
62 config: &ConfigFile,
63 bench_name: &str,
64 baseline: Option<&RunReceipt>,
65 tool: ToolInfo,
66 options: SensorCheckOptions,
67) -> SensorReport
68where
69 R: ProcessRunner + Clone,
70 H: HostProbe + Clone,
71 C: Clock + Clone,
72{
73 let started_at = clock.now_rfc3339();
74 let start_instant = std::time::Instant::now();
75
76 if let Err(err) = validate_bench_name(bench_name) {
78 let ended_at = clock.now_rfc3339();
79 let duration_ms = start_instant.elapsed().as_millis() as u64;
80 let builder = SensorReportBuilder::new(tool, started_at)
81 .ended_at(ended_at, duration_ms)
82 .baseline(baseline.is_some(), None);
83 return builder.build_error(&err.to_string(), STAGE_CONFIG_PARSE, ERROR_KIND_PARSE);
84 }
85
86 if let Err(msg) = config.validate() {
89 let ended_at = clock.now_rfc3339();
90 let duration_ms = start_instant.elapsed().as_millis() as u64;
91 let builder = SensorReportBuilder::new(tool, started_at)
92 .ended_at(ended_at, duration_ms)
93 .baseline(baseline.is_some(), None);
94 return builder.build_error(
95 &format!("config validation: {}", msg),
96 STAGE_CONFIG_PARSE,
97 ERROR_KIND_PARSE,
98 );
99 }
100
101 let baseline_available = baseline.is_some();
102
103 let result = CheckUseCase::new(runner.clone(), host_probe.clone(), clock.clone()).execute(
104 CheckRequest {
105 config: config.clone(),
106 bench_name: bench_name.to_string(),
107 out_dir: std::path::PathBuf::from("."), baseline: baseline.cloned(),
109 baseline_path: None,
110 require_baseline: options.require_baseline,
111 fail_on_warn: options.fail_on_warn,
112 tool: tool.clone(),
113 env: options.env.clone(),
114 output_cap_bytes: options.output_cap_bytes,
115 allow_nonzero: options.allow_nonzero,
116 host_mismatch_policy: options.host_mismatch_policy,
117 significance_alpha: None,
118 significance_min_samples: 8,
119 require_significance: false,
120 },
121 );
122
123 let ended_at = clock.now_rfc3339();
124 let duration_ms = start_instant.elapsed().as_millis() as u64;
125
126 let baseline_reason = if !baseline_available {
127 Some(BASELINE_REASON_NO_BASELINE.to_string())
128 } else {
129 None
130 };
131
132 match result {
133 Ok(outcome) => {
134 let mut builder = SensorReportBuilder::new(tool, started_at)
135 .ended_at(ended_at, duration_ms)
136 .baseline(baseline_available, baseline_reason);
137
138 if let Some(limit) = options.max_findings {
139 builder = builder.max_findings(limit);
140 }
141
142 builder.build(&outcome.report)
143 }
144 Err(err) => {
145 let (stage, error_kind) = classify_error(&err);
146 let builder = SensorReportBuilder::new(tool, started_at)
147 .ended_at(ended_at, duration_ms)
148 .baseline(baseline_available, baseline_reason);
149
150 builder.build_error(&format!("{:#}", err), stage, error_kind)
151 }
152 }
153}
154
155pub fn classify_error(err: &anyhow::Error) -> (&'static str, &'static str) {
157 if err.downcast_ref::<ConfigValidationError>().is_some() {
159 return (STAGE_CONFIG_PARSE, ERROR_KIND_PARSE);
160 }
161
162 if let Some(pe) = err.downcast_ref::<PerfgateError>() {
163 return match pe {
164 PerfgateError::BaselineResolve(_) => (STAGE_BASELINE_RESOLVE, ERROR_KIND_IO),
165 PerfgateError::ArtifactWrite(_) => (STAGE_WRITE_ARTIFACTS, ERROR_KIND_IO),
166 PerfgateError::RunCommand(_) => (STAGE_RUN_COMMAND, ERROR_KIND_EXEC),
167 PerfgateError::Other(_) => (STAGE_RUN_COMMAND, ERROR_KIND_IO),
168 };
169 }
170
171 if let Some(ae) = err.downcast_ref::<AdapterError>() {
173 return match ae {
174 AdapterError::EmptyArgv | AdapterError::Timeout | AdapterError::TimeoutUnsupported => {
175 (STAGE_RUN_COMMAND, ERROR_KIND_EXEC)
176 }
177 AdapterError::Other(_) => (STAGE_RUN_COMMAND, ERROR_KIND_IO),
178 };
179 }
180
181 if err.downcast_ref::<perfgate_domain::DomainError>().is_some() {
183 return (STAGE_RUN_COMMAND, ERROR_KIND_EXEC);
184 }
185
186 let msg = format!("{:#}", err);
188 let msg_lower = msg.to_lowercase();
189
190 if msg_lower.contains("bench name")
191 || msg_lower.contains("config validation")
192 || (msg_lower.contains("parse")
193 && (msg_lower.contains("toml")
194 || msg_lower.contains("json config")
195 || msg_lower.contains("config")))
196 {
197 (STAGE_CONFIG_PARSE, ERROR_KIND_PARSE)
198 } else if msg_lower.contains("baseline") || msg_lower.contains("not found") {
199 (STAGE_BASELINE_RESOLVE, ERROR_KIND_IO)
200 } else if msg_lower.contains("failed to run")
201 || msg_lower.contains("spawn")
202 || msg_lower.contains("exec")
203 {
204 (STAGE_RUN_COMMAND, ERROR_KIND_EXEC)
205 } else if msg_lower.contains("write")
206 || msg_lower.contains("create dir")
207 || msg_lower.contains("rename")
208 {
209 (STAGE_WRITE_ARTIFACTS, ERROR_KIND_IO)
210 } else if err.downcast_ref::<std::io::Error>().is_some() {
211 (STAGE_RUN_COMMAND, ERROR_KIND_IO)
212 } else {
213 (STAGE_RUN_COMMAND, ERROR_KIND_EXEC)
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use perfgate_adapters::FakeProcessRunner;
221 use perfgate_types::{
222 ERROR_KIND_EXEC, ERROR_KIND_PARSE, PerfgateError, REPORT_SCHEMA_V1, ReportSummary,
223 SensorVerdictStatus, Verdict, VerdictCounts, VerdictStatus,
224 };
225
226 fn make_tool_info() -> ToolInfo {
227 ToolInfo {
228 name: "perfgate".to_string(),
229 version: "0.1.0".to_string(),
230 }
231 }
232
233 fn make_pass_report() -> perfgate_types::PerfgateReport {
234 perfgate_types::PerfgateReport {
235 report_type: REPORT_SCHEMA_V1.to_string(),
236 verdict: Verdict {
237 status: VerdictStatus::Pass,
238 counts: VerdictCounts {
239 pass: 2,
240 warn: 0,
241 fail: 0,
242 },
243 reasons: vec![],
244 },
245 compare: None,
246 findings: vec![],
247 summary: ReportSummary {
248 pass_count: 2,
249 warn_count: 0,
250 fail_count: 0,
251 total_count: 2,
252 },
253 }
254 }
255
256 #[test]
257 fn test_classify_error_config_parse() {
258 let err = anyhow::anyhow!("parse TOML config perfgate.toml: expected `=`");
259 let (stage, kind) = classify_error(&err);
260 assert_eq!(stage, STAGE_CONFIG_PARSE);
261 assert_eq!(kind, ERROR_KIND_PARSE);
262 }
263
264 #[test]
265 fn test_classify_error_baseline_resolve() {
266 let err = anyhow::anyhow!("baseline file not found");
267 let (stage, kind) = classify_error(&err);
268 assert_eq!(stage, STAGE_BASELINE_RESOLVE);
269 assert_eq!(kind, ERROR_KIND_IO);
270 }
271
272 #[test]
273 fn test_classify_error_default_exec() {
274 let err = anyhow::anyhow!("something unexpected happened");
275 let (stage, kind) = classify_error(&err);
276 assert_eq!(stage, STAGE_RUN_COMMAND);
277 assert_eq!(kind, ERROR_KIND_EXEC);
278 }
279
280 #[test]
281 fn test_classify_error_spawn_failure() {
282 let err = anyhow::anyhow!("failed to run command: spawn error");
283 let (stage, kind) = classify_error(&err);
284 assert_eq!(stage, STAGE_RUN_COMMAND);
285 assert_eq!(kind, ERROR_KIND_EXEC);
286 }
287
288 #[test]
289 fn test_classify_error_exec_failure() {
290 let err = anyhow::anyhow!("exec failed for process");
291 let (stage, kind) = classify_error(&err);
292 assert_eq!(stage, STAGE_RUN_COMMAND);
293 assert_eq!(kind, ERROR_KIND_EXEC);
294 }
295
296 #[test]
297 fn test_classify_error_write_artifacts() {
298 let err = anyhow::anyhow!("write output file failed");
299 let (stage, kind) = classify_error(&err);
300 assert_eq!(stage, STAGE_WRITE_ARTIFACTS);
301 assert_eq!(kind, ERROR_KIND_IO);
302 }
303
304 #[test]
305 fn test_classify_error_create_dir() {
306 let err = anyhow::anyhow!("create dir /tmp/out: permission denied");
307 let (stage, kind) = classify_error(&err);
308 assert_eq!(stage, STAGE_WRITE_ARTIFACTS);
309 assert_eq!(kind, ERROR_KIND_IO);
310 }
311
312 #[test]
313 fn test_classify_error_rename() {
314 let err = anyhow::anyhow!("rename temp file: cross-device link");
315 let (stage, kind) = classify_error(&err);
316 assert_eq!(stage, STAGE_WRITE_ARTIFACTS);
317 assert_eq!(kind, ERROR_KIND_IO);
318 }
319
320 #[test]
321 fn test_classify_error_io_downcast() {
322 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file gone");
323 let err: anyhow::Error = io_err.into();
324 let (stage, kind) = classify_error(&err);
325 assert_eq!(stage, STAGE_RUN_COMMAND);
326 assert_eq!(kind, ERROR_KIND_IO);
327 }
328
329 #[test]
330 fn test_classify_error_json_config() {
331 let err = anyhow::anyhow!("parse JSON config perfgate.json: unexpected token");
332 let (stage, kind) = classify_error(&err);
333 assert_eq!(stage, STAGE_CONFIG_PARSE);
334 assert_eq!(kind, ERROR_KIND_PARSE);
335 }
336
337 #[test]
338 fn test_classify_error_generic_config_parse() {
339 let err = anyhow::anyhow!("parse config perfgate");
340 let (stage, kind) = classify_error(&err);
341 assert_eq!(stage, STAGE_CONFIG_PARSE);
342 assert_eq!(kind, ERROR_KIND_PARSE);
343 }
344
345 #[test]
346 fn test_classify_error_bench_name_validation() {
347 let err = anyhow::anyhow!(
348 "bench name \"../evil\" contains a \"..\" path segment (path traversal is forbidden)"
349 );
350 let (stage, kind) = classify_error(&err);
351 assert_eq!(stage, STAGE_CONFIG_PARSE);
352 assert_eq!(kind, ERROR_KIND_PARSE);
353 }
354
355 #[test]
356 fn test_classify_error_config_validation() {
357 let err =
358 anyhow::anyhow!("config validation: bench name \"Bad\" contains invalid characters");
359 let (stage, kind) = classify_error(&err);
360 assert_eq!(stage, STAGE_CONFIG_PARSE);
361 assert_eq!(kind, ERROR_KIND_PARSE);
362 }
363
364 #[test]
365 fn test_classify_error_config_validation_without_bench_name() {
366 let err = anyhow::anyhow!("config validation: some future validation error");
367 let (stage, kind) = classify_error(&err);
368 assert_eq!(stage, STAGE_CONFIG_PARSE);
369 assert_eq!(kind, ERROR_KIND_PARSE);
370 }
371
372 #[test]
373 fn test_classify_error_config_validation_typed_config_file() {
374 let err: anyhow::Error = ConfigValidationError::ConfigFile(
375 "bench name \"Bad\" contains invalid characters".to_string(),
376 )
377 .into();
378 let (stage, kind) = classify_error(&err);
379 assert_eq!(stage, STAGE_CONFIG_PARSE);
380 assert_eq!(kind, ERROR_KIND_PARSE);
381 }
382
383 #[test]
384 fn test_classify_error_config_validation_typed_bench_name() {
385 let err: anyhow::Error =
386 ConfigValidationError::BenchName("bench name must not be empty".to_string()).into();
387 let (stage, kind) = classify_error(&err);
388 assert_eq!(stage, STAGE_CONFIG_PARSE);
389 assert_eq!(kind, ERROR_KIND_PARSE);
390 }
391
392 #[test]
395 fn test_classify_error_typed_baseline_resolve() {
396 let err: anyhow::Error =
397 PerfgateError::BaselineResolve("file not found".to_string()).into();
398 let (stage, kind) = classify_error(&err);
399 assert_eq!(stage, STAGE_BASELINE_RESOLVE);
400 assert_eq!(kind, ERROR_KIND_IO);
401 }
402
403 #[test]
404 fn test_classify_error_typed_artifact_write() {
405 let err: anyhow::Error =
406 PerfgateError::ArtifactWrite("permission denied".to_string()).into();
407 let (stage, kind) = classify_error(&err);
408 assert_eq!(stage, STAGE_WRITE_ARTIFACTS);
409 assert_eq!(kind, ERROR_KIND_IO);
410 }
411
412 #[test]
413 fn test_classify_error_typed_run_command() {
414 let err: anyhow::Error = PerfgateError::RunCommand("spawn failed".to_string()).into();
415 let (stage, kind) = classify_error(&err);
416 assert_eq!(stage, STAGE_RUN_COMMAND);
417 assert_eq!(kind, ERROR_KIND_EXEC);
418 }
419
420 #[test]
423 fn test_classify_error_adapter_empty_argv() {
424 let err: anyhow::Error = AdapterError::EmptyArgv.into();
425 let (stage, kind) = classify_error(&err);
426 assert_eq!(stage, STAGE_RUN_COMMAND);
427 assert_eq!(kind, ERROR_KIND_EXEC);
428 }
429
430 #[test]
431 fn test_classify_error_adapter_timeout() {
432 let err: anyhow::Error = AdapterError::Timeout.into();
433 let (stage, kind) = classify_error(&err);
434 assert_eq!(stage, STAGE_RUN_COMMAND);
435 assert_eq!(kind, ERROR_KIND_EXEC);
436 }
437
438 #[test]
439 fn test_classify_error_adapter_timeout_unsupported() {
440 let err: anyhow::Error = AdapterError::TimeoutUnsupported.into();
441 let (stage, kind) = classify_error(&err);
442 assert_eq!(stage, STAGE_RUN_COMMAND);
443 assert_eq!(kind, ERROR_KIND_EXEC);
444 }
445
446 #[test]
447 fn test_classify_error_adapter_other() {
448 let inner = anyhow::anyhow!("some IO problem");
449 let err: anyhow::Error = AdapterError::Other(inner).into();
450 let (stage, kind) = classify_error(&err);
451 assert_eq!(stage, STAGE_RUN_COMMAND);
452 assert_eq!(kind, ERROR_KIND_IO);
453 }
454
455 #[test]
458 fn test_classify_error_domain_no_samples() {
459 let err: anyhow::Error = perfgate_domain::DomainError::NoSamples.into();
460 let (stage, kind) = classify_error(&err);
461 assert_eq!(stage, STAGE_RUN_COMMAND);
462 assert_eq!(kind, ERROR_KIND_EXEC);
463 }
464
465 #[test]
468 fn test_classify_error_bench_not_found_typed() {
469 let err: anyhow::Error =
470 ConfigValidationError::BenchName("bench 'xyz' not found in config".to_string()).into();
471 let (stage, kind) = classify_error(&err);
472 assert_eq!(stage, STAGE_CONFIG_PARSE);
473 assert_eq!(kind, ERROR_KIND_PARSE);
474 }
475
476 #[derive(Clone)]
479 struct TestHostProbe {
480 host: perfgate_types::HostInfo,
481 }
482
483 impl TestHostProbe {
484 fn new(host: perfgate_types::HostInfo) -> Self {
485 Self { host }
486 }
487 }
488
489 impl HostProbe for TestHostProbe {
490 fn probe(
491 &self,
492 _options: &perfgate_adapters::HostProbeOptions,
493 ) -> perfgate_types::HostInfo {
494 self.host.clone()
495 }
496 }
497
498 #[derive(Clone)]
499 struct TestClock {
500 now: String,
501 }
502
503 impl TestClock {
504 fn new(now: &str) -> Self {
505 Self {
506 now: now.to_string(),
507 }
508 }
509 }
510
511 impl Clock for TestClock {
512 fn now_rfc3339(&self) -> String {
513 self.now.clone()
514 }
515 }
516
517 #[test]
518 fn test_run_sensor_check_deterministic() {
519 use perfgate_adapters::RunResult;
520
521 let runner = FakeProcessRunner::new();
522 runner.set_fallback(RunResult {
523 wall_ms: 100,
524 exit_code: 0,
525 timed_out: false,
526 cpu_ms: Some(50),
527 page_faults: None,
528 ctx_switches: None,
529 max_rss_kb: Some(2048),
530 binary_bytes: None,
531 stdout: b"ok".to_vec(),
532 stderr: b"".to_vec(),
533 });
534
535 let host_probe = TestHostProbe::new(perfgate_types::HostInfo {
536 os: "linux".to_string(),
537 arch: "x86_64".to_string(),
538 cpu_count: Some(4),
539 memory_bytes: Some(8 * 1024 * 1024 * 1024),
540 hostname_hash: None,
541 });
542 let clock = TestClock::new("2024-01-01T00:00:00Z");
543
544 let config = ConfigFile {
545 defaults: perfgate_types::DefaultsConfig::default(),
546 baseline_server: perfgate_types::BaselineServerConfig::default(),
547 benches: vec![perfgate_types::BenchConfigFile {
548 name: "test-bench".to_string(),
549 cwd: None,
550 work: None,
551 timeout: None,
552 command: vec!["true".to_string()],
553 repeat: None,
554 warmup: None,
555 metrics: None,
556 budgets: None,
557 }],
558 };
559
560 let baseline = perfgate_types::RunReceipt {
561 schema: perfgate_types::RUN_SCHEMA_V1.to_string(),
562 tool: make_tool_info(),
563 run: perfgate_types::RunMeta {
564 id: "baseline".to_string(),
565 started_at: "2024-01-01T00:00:00Z".to_string(),
566 ended_at: "2024-01-01T00:00:01Z".to_string(),
567 host: host_probe.probe(&perfgate_adapters::HostProbeOptions::default()),
568 },
569 bench: perfgate_types::BenchMeta {
570 name: "test-bench".to_string(),
571 cwd: None,
572 command: vec!["true".to_string()],
573 repeat: 1,
574 warmup: 0,
575 work_units: None,
576 timeout_ms: None,
577 },
578 samples: vec![],
579 stats: perfgate_types::Stats {
580 wall_ms: perfgate_types::U64Summary {
581 median: 50,
582 min: 50,
583 max: 50,
584 },
585 cpu_ms: None,
586 page_faults: None,
587 ctx_switches: None,
588 max_rss_kb: None,
589 binary_bytes: None,
590 throughput_per_s: None,
591 },
592 };
593
594 let report = run_sensor_check(
595 &runner,
596 &host_probe,
597 &clock,
598 &config,
599 "test-bench",
600 Some(&baseline),
601 make_tool_info(),
602 SensorCheckOptions::default(),
603 );
604
605 assert_eq!(report.verdict.status, SensorVerdictStatus::Fail);
606 assert_eq!(report.verdict.counts.error, 1);
607 assert!(report.findings[0].message.contains("wall_ms regression"));
608 }
609
610 #[test]
613 fn test_reexported_sensor_report_builder() {
614 let report = make_pass_report();
615 let builder =
616 SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
617 .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
618 .baseline(true, None);
619
620 let sensor_report = builder.build(&report);
621
622 assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Pass);
623 }
624
625 #[test]
626 fn test_reexported_sensor_fingerprint() {
627 let fp = sensor_fingerprint(&["perfgate", "perf.budget", "metric_fail"]);
628 assert_eq!(fp.len(), 64);
629 }
630
631 #[test]
632 fn test_reexported_default_engine_capability() {
633 let cap = default_engine_capability();
634 if cfg!(unix) {
635 assert_eq!(cap.status, perfgate_types::CapabilityStatus::Available);
636 } else {
637 assert_eq!(cap.status, perfgate_types::CapabilityStatus::Unavailable);
638 }
639 }
640
641 #[test]
642 fn test_reexported_bench_outcome() {
643 let outcome = BenchOutcome::Success {
644 bench_name: "test".to_string(),
645 report: make_pass_report(),
646 has_compare: false,
647 baseline_available: false,
648 markdown: "## test".to_string(),
649 extras_prefix: "extras".to_string(),
650 };
651 assert_eq!(outcome.bench_name(), "test");
652 }
653}