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::{HostProbe, ProcessRunner};
16use perfgate_error::{AdapterError, ConfigValidationError, IoError, PerfgateError};
17use perfgate_types::{
18    BASELINE_REASON_NO_BASELINE, ConfigFile, ERROR_KIND_EXEC, ERROR_KIND_IO, ERROR_KIND_PARSE,
19    HostMismatchPolicy, MAX_FINDINGS_DEFAULT, RunReceipt, STAGE_BASELINE_RESOLVE,
20    STAGE_CONFIG_PARSE, STAGE_RUN_COMMAND, STAGE_WRITE_ARTIFACTS, SensorReport, ToolInfo,
21    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#[allow(clippy::too_many_arguments)]
52pub fn run_sensor_check<R, H, C>(
53    runner: &R,
54    host_probe: &H,
55    clock: &C,
56    config: &ConfigFile,
57    bench_name: &str,
58    baseline: Option<&RunReceipt>,
59    tool: ToolInfo,
60    options: SensorCheckOptions,
61) -> SensorReport
62where
63    R: ProcessRunner + Clone,
64    H: HostProbe + Clone,
65    C: Clock + Clone,
66{
67    let started_at = clock.now_rfc3339();
68    let start_instant = std::time::Instant::now();
69
70    // Validate bench name early
71    if let Err(err) = validate_bench_name(bench_name) {
72        let ended_at = clock.now_rfc3339();
73        let duration_ms = start_instant.elapsed().as_millis() as u64;
74        let builder = SensorReportBuilder::new(tool, started_at)
75            .ended_at(ended_at, duration_ms)
76            .baseline(baseline.is_some(), None);
77        return builder.build_error(&err.to_string(), STAGE_CONFIG_PARSE, ERROR_KIND_PARSE);
78    }
79
80    // Validate config
81    if let Err(msg) = config.validate() {
82        let ended_at = clock.now_rfc3339();
83        let duration_ms = start_instant.elapsed().as_millis() as u64;
84        let builder = SensorReportBuilder::new(tool, started_at)
85            .ended_at(ended_at, duration_ms)
86            .baseline(baseline.is_some(), None);
87        return builder.build_error(
88            &format!("config validation: {}", msg),
89            STAGE_CONFIG_PARSE,
90            ERROR_KIND_PARSE,
91        );
92    }
93
94    let baseline_available = baseline.is_some();
95
96    let result = CheckUseCase::new(runner.clone(), host_probe.clone(), clock.clone()).execute(
97        CheckRequest {
98            noise_threshold: None,
99            noise_policy: None,
100            config: config.clone(),
101            bench_name: bench_name.to_string(),
102            out_dir: std::path::PathBuf::from("."),
103            baseline: baseline.cloned(),
104            baseline_path: None,
105            require_baseline: options.require_baseline,
106            fail_on_warn: options.fail_on_warn,
107            tool: tool.clone(),
108            env: options.env.clone(),
109            output_cap_bytes: options.output_cap_bytes,
110            allow_nonzero: options.allow_nonzero,
111            host_mismatch_policy: options.host_mismatch_policy,
112            significance_alpha: None,
113            significance_min_samples: 8,
114            require_significance: false,
115        },
116    );
117
118    let ended_at = clock.now_rfc3339();
119    let duration_ms = start_instant.elapsed().as_millis() as u64;
120
121    let baseline_reason = if !baseline_available {
122        Some(BASELINE_REASON_NO_BASELINE.to_string())
123    } else {
124        None
125    };
126
127    match result {
128        Ok(outcome) => {
129            let mut builder = SensorReportBuilder::new(tool, started_at)
130                .ended_at(ended_at, duration_ms)
131                .baseline(baseline_available, baseline_reason);
132
133            if let Some(limit) = options.max_findings {
134                builder = builder.max_findings(limit);
135            }
136
137            builder.build(&outcome.report)
138        }
139        Err(err) => {
140            let (stage, error_kind) = classify_error(&err);
141            let builder = SensorReportBuilder::new(tool, started_at)
142                .ended_at(ended_at, duration_ms)
143                .baseline(baseline_available, baseline_reason);
144
145            builder.build_error(&err.to_string(), stage, error_kind)
146        }
147    }
148}
149
150/// Classify an error into (stage, error_kind) for structured error reporting.
151pub fn classify_error(err: &anyhow::Error) -> (&'static str, &'static str) {
152    if err.downcast_ref::<ConfigValidationError>().is_some() {
153        return (STAGE_CONFIG_PARSE, ERROR_KIND_PARSE);
154    }
155
156    if let Some(pe) = err.downcast_ref::<PerfgateError>() {
157        return match pe {
158            PerfgateError::Validation(_) => (STAGE_CONFIG_PARSE, ERROR_KIND_PARSE),
159            PerfgateError::Config(_) => (STAGE_CONFIG_PARSE, ERROR_KIND_PARSE),
160            PerfgateError::Adapter(ae) => match ae {
161                AdapterError::Timeout => (STAGE_RUN_COMMAND, ERROR_KIND_EXEC),
162                AdapterError::EmptyArgv => (STAGE_RUN_COMMAND, ERROR_KIND_EXEC),
163                AdapterError::TimeoutUnsupported => (STAGE_RUN_COMMAND, ERROR_KIND_EXEC),
164                AdapterError::RunCommand { .. } => (STAGE_RUN_COMMAND, ERROR_KIND_EXEC),
165                AdapterError::Other(_) => (STAGE_RUN_COMMAND, ERROR_KIND_IO),
166            },
167            PerfgateError::Io(ie) => match ie {
168                IoError::BaselineNotFound { .. } => (STAGE_BASELINE_RESOLVE, ERROR_KIND_IO),
169                IoError::BaselineResolve(_) => (STAGE_BASELINE_RESOLVE, ERROR_KIND_IO),
170                IoError::ArtifactWrite(_) => (STAGE_WRITE_ARTIFACTS, ERROR_KIND_IO),
171                IoError::RunCommand { .. } => (STAGE_RUN_COMMAND, ERROR_KIND_EXEC),
172                IoError::Other(_) => (STAGE_RUN_COMMAND, ERROR_KIND_IO),
173            },
174            PerfgateError::Stats(_) => (STAGE_RUN_COMMAND, ERROR_KIND_PARSE),
175            PerfgateError::Paired(_) => (STAGE_RUN_COMMAND, ERROR_KIND_PARSE),
176            PerfgateError::Auth(_) => (STAGE_BASELINE_RESOLVE, ERROR_KIND_IO),
177        };
178    }
179
180    if err.downcast_ref::<perfgate_domain::DomainError>().is_some() {
181        return (STAGE_RUN_COMMAND, ERROR_KIND_EXEC);
182    }
183
184    let msg_lower = err.to_string().to_lowercase();
185
186    if msg_lower.contains("config") || msg_lower.contains("toml") || msg_lower.contains("json") {
187        (STAGE_CONFIG_PARSE, ERROR_KIND_PARSE)
188    } else if msg_lower.contains("baseline") {
189        (STAGE_BASELINE_RESOLVE, ERROR_KIND_IO)
190    } else if msg_lower.contains("failed to run")
191        || msg_lower.contains("spawn")
192        || msg_lower.contains("exec")
193    {
194        (STAGE_RUN_COMMAND, ERROR_KIND_EXEC)
195    } else if msg_lower.contains("write") || msg_lower.contains("permission") {
196        (STAGE_WRITE_ARTIFACTS, ERROR_KIND_IO)
197    } else {
198        (STAGE_RUN_COMMAND, ERROR_KIND_IO)
199    }
200}