Skip to main content

perfgate_app/
sensor_report.rs

1//! Conversion from PerfgateReport to SensorReport envelope.
2//!
3//! This module provides `run_sensor_check()`, a library-linkable convenience function
4//! so the cockpit binary can `use perfgate_app::run_sensor_check`.
5//!
6//! The sensor report building functionality is provided by the `perfgate-sensor` crate.
7//! This module re-exports those types and functions for backward compatibility.
8
9// Re-export sensor building functionality from perfgate-sensor
10pub 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/// Options for `run_sensor_check`.
25#[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/// Run a sensor check and return a `SensorReport` directly.
51///
52/// This is the library convenience API for cockpit linking. It delegates to
53/// `CheckUseCase::execute()`, wraps the result in a `SensorReportBuilder`,
54/// and catches errors to produce an error report.
55///
56/// Returns `SensorReport` directly — no I/O, no file writing.
57#[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    // Validate bench name early — invalid names produce an error report.
77    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    // Validate config (covers other bench names in the file) — defense-in-depth
87    // so library callers can't bypass config-level validation.
88    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("."), // artifacts not written
108            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
155/// Classify an error into (stage, error_kind) for structured error reporting.
156pub fn classify_error(err: &anyhow::Error) -> (&'static str, &'static str) {
157    // Structural classification — preferred over string matching.
158    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    // Walk the error chain for AdapterError.
172    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    // Walk the chain for DomainError.
182    if err.downcast_ref::<perfgate_domain::DomainError>().is_some() {
183        return (STAGE_RUN_COMMAND, ERROR_KIND_EXEC);
184    }
185
186    // Fallback: string heuristics for errors not yet converted to typed errors.
187    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    // --- Typed PerfgateError downcast tests ---
393
394    #[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    // --- AdapterError downcast tests ---
421
422    #[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    // --- DomainError downcast test ---
456
457    #[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    // --- Bench-not-found misclassification bug regression test ---
466
467    #[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    // --- run_sensor_check integration test ---
477
478    #[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    // Tests for re-exported types from perfgate-sensor
611
612    #[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}