Skip to main content

perfgate_sensor/
lib.rs

1//! Sensor report building for cockpit integration.
2//!
3//! This crate provides the `SensorReportBuilder` for wrapping a `PerfgateReport`
4//! into a `sensor.report.v1` envelope suitable for cockpit integration.
5//!
6//! # Overview
7//!
8//! The sensor report is a standardized envelope format for CI/CD cockpit systems.
9//! It wraps performance benchmark results with:
10//! - Tool metadata (name, version)
11//! - Run metadata (timestamps, duration)
12//! - Capabilities (baseline availability, engine features)
13//! - Verdict (pass/warn/fail with counts)
14//! - Findings (individual check results with fingerprints)
15//! - Artifacts (links to detailed reports)
16//!
17//! # Example
18//!
19//! ```rust
20//! use perfgate_sensor::{SensorReportBuilder, sensor_fingerprint, default_engine_capability};
21//! use perfgate_types::{ToolInfo, PerfgateReport, SensorReport, CapabilityStatus};
22//!
23//! let tool = ToolInfo {
24//!     name: "perfgate".to_string(),
25//!     version: "0.1.0".to_string(),
26//! };
27//!
28//! // Build a fingerprint for a finding
29//! let fp = sensor_fingerprint(&["perfgate", "perf.budget", "metric_fail", "wall_ms"]);
30//! assert_eq!(fp.len(), 64); // SHA-256 hex
31//!
32//! // Check default engine capability (varies by platform)
33//! let cap = default_engine_capability();
34//! if cfg!(unix) {
35//!     assert_eq!(cap.status, CapabilityStatus::Available);
36//! } else {
37//!     assert_eq!(cap.status, CapabilityStatus::Unavailable);
38//! }
39//!
40//! // Build a sensor report (with a minimal PerfgateReport)
41//! // See SensorReportBuilder documentation for full example.
42//! ```
43
44use perfgate_sha256::sha256_hex;
45use perfgate_types::{
46    BASELINE_REASON_NO_BASELINE, CHECK_ID_TOOL_RUNTIME, CHECK_ID_TOOL_TRUNCATION, Capability,
47    CapabilityStatus, FINDING_CODE_RUNTIME_ERROR, FINDING_CODE_TRUNCATED, MAX_FINDINGS_DEFAULT,
48    PerfgateReport, SENSOR_REPORT_SCHEMA_V1, SensorArtifact, SensorCapabilities, SensorFinding,
49    SensorReport, SensorRunMeta, SensorSeverity, SensorVerdict, SensorVerdictCounts,
50    SensorVerdictStatus, Severity, ToolInfo, VERDICT_REASON_TOOL_ERROR, VERDICT_REASON_TRUNCATED,
51    VerdictStatus,
52};
53
54/// Build a fleet-standard fingerprint from semantic parts.
55///
56/// The fingerprint is a SHA-256 hash of the pipe-joined parts, with trailing
57/// empty parts trimmed. This provides a stable, deterministic identifier for
58/// findings that can be used for deduplication and tracking.
59///
60/// # Example
61///
62/// ```rust
63/// use perfgate_sensor::sensor_fingerprint;
64///
65/// let fp = sensor_fingerprint(&["tool", "check", "code", "metric"]);
66/// assert_eq!(fp.len(), 64); // SHA-256 hex string
67///
68/// // Trailing empty parts are trimmed
69/// let fp1 = sensor_fingerprint(&["a", "b", ""]);
70/// let fp2 = sensor_fingerprint(&["a", "b"]);
71/// assert_eq!(fp1, fp2);
72///
73/// // Different inputs produce different fingerprints
74/// let fp3 = sensor_fingerprint(&["a", "c"]);
75/// assert_ne!(fp1, fp3);
76/// ```
77pub fn sensor_fingerprint(parts: &[&str]) -> String {
78    let trimmed: Vec<&str> = parts
79        .iter()
80        .rev()
81        .skip_while(|s| s.is_empty())
82        .collect::<Vec<_>>()
83        .into_iter()
84        .rev()
85        .copied()
86        .collect();
87    sha256_hex(trimmed.join("|").as_bytes())
88}
89
90/// Build a default engine capability based on the current platform.
91///
92/// On Unix platforms, returns `CapabilityStatus::Available` because
93/// the engine can collect `cpu_ms` and `max_rss_kb` via `wait4()`.
94///
95/// On non-Unix platforms, returns `CapabilityStatus::Unavailable` with
96/// reason `"platform_limited"` because these metrics are not available.
97///
98/// # Example
99///
100/// ```rust
101/// use perfgate_sensor::default_engine_capability;
102/// use perfgate_types::CapabilityStatus;
103///
104/// let cap = default_engine_capability();
105/// if cfg!(unix) {
106///     assert_eq!(cap.status, CapabilityStatus::Available);
107///     assert!(cap.reason.is_none());
108/// } else {
109///     assert_eq!(cap.status, CapabilityStatus::Unavailable);
110///     assert_eq!(cap.reason, Some("platform_limited".to_string()));
111/// }
112/// ```
113pub fn default_engine_capability() -> Capability {
114    if cfg!(unix) {
115        Capability {
116            status: CapabilityStatus::Available,
117            reason: None,
118        }
119    } else {
120        Capability {
121            status: CapabilityStatus::Unavailable,
122            reason: Some("platform_limited".to_string()),
123        }
124    }
125}
126
127/// Apply truncation to a findings vector if it exceeds the given limit.
128///
129/// When truncation is applied:
130/// - `findings` is truncated to `limit - 1` real findings, then a meta-finding is appended
131/// - `reasons` gets `VERDICT_REASON_TRUNCATED` appended (if not already present)
132/// - Returns `Some((total, shown))` where total is the original count and shown is the emitted count
133///
134/// When not applied (under limit): returns `None` and leaves inputs unchanged.
135fn truncate_findings(
136    findings: &mut Vec<SensorFinding>,
137    reasons: &mut Vec<String>,
138    limit: usize,
139    tool_name: &str,
140) -> Option<(usize, usize)> {
141    if findings.len() <= limit {
142        return None;
143    }
144    let total = findings.len();
145    let shown = limit.saturating_sub(1);
146    findings.truncate(shown);
147    findings.push(SensorFinding {
148        check_id: CHECK_ID_TOOL_TRUNCATION.to_string(),
149        code: FINDING_CODE_TRUNCATED.to_string(),
150        severity: SensorSeverity::Info,
151        message: format!(
152            "Showing {} of {} findings; {} omitted",
153            shown,
154            total,
155            total - shown
156        ),
157        fingerprint: Some(sensor_fingerprint(&[
158            tool_name,
159            CHECK_ID_TOOL_TRUNCATION,
160            FINDING_CODE_TRUNCATED,
161        ])),
162        data: Some(serde_json::json!({
163            "total_findings": total,
164            "shown_findings": shown,
165        })),
166    });
167    if !reasons.contains(&VERDICT_REASON_TRUNCATED.to_string()) {
168        reasons.push(VERDICT_REASON_TRUNCATED.to_string());
169    }
170    Some((total, shown))
171}
172
173/// A single bench's outcome for aggregation into a sensor report.
174#[allow(clippy::large_enum_variant)]
175pub enum BenchOutcome {
176    /// The bench ran successfully and produced a report.
177    Success {
178        bench_name: String,
179        report: PerfgateReport,
180        has_compare: bool,
181        baseline_available: bool,
182        markdown: String,
183        extras_prefix: String,
184    },
185    /// The bench failed with an error before producing a report.
186    Error {
187        bench_name: String,
188        error_message: String,
189        stage: &'static str,
190        error_kind: &'static str,
191    },
192}
193
194impl BenchOutcome {
195    /// Get the bench name regardless of variant.
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// use perfgate_sensor::BenchOutcome;
201    ///
202    /// let outcome = BenchOutcome::Error {
203    ///     bench_name: "my-bench".to_string(),
204    ///     error_message: "oops".to_string(),
205    ///     stage: "run",
206    ///     error_kind: "exec_error",
207    /// };
208    /// assert_eq!(outcome.bench_name(), "my-bench");
209    /// ```
210    pub fn bench_name(&self) -> &str {
211        match self {
212            BenchOutcome::Success { bench_name, .. } => bench_name,
213            BenchOutcome::Error { bench_name, .. } => bench_name,
214        }
215    }
216}
217
218/// Builder for constructing a SensorReport from a PerfgateReport.
219///
220/// This builder provides a fluent API for constructing sensor reports
221/// suitable for cockpit integration. It handles:
222/// - Mapping verdict status (Pass/Warn/Fail) to sensor vocabulary (Pass/Warn/Error)
223/// - Generating fingerprints for findings
224/// - Truncating findings when limits are exceeded
225/// - Aggregating multiple bench outcomes
226/// - Sorting artifacts deterministically
227///
228/// # Example
229///
230/// ```rust
231/// use perfgate_sensor::SensorReportBuilder;
232/// use perfgate_types::{ToolInfo, PerfgateReport, VerdictStatus, Verdict, VerdictCounts, ReportSummary, REPORT_SCHEMA_V1};
233///
234/// let tool = ToolInfo {
235///     name: "perfgate".to_string(),
236///     version: "0.1.0".to_string(),
237/// };
238///
239/// let report = PerfgateReport {
240///     report_type: REPORT_SCHEMA_V1.to_string(),
241///     verdict: Verdict {
242///         status: VerdictStatus::Pass,
243///         counts: VerdictCounts { pass: 2, warn: 0, fail: 0 },
244///         reasons: vec![],
245///     },
246///     compare: None,
247///     findings: vec![],
248///     summary: ReportSummary { pass_count: 2, warn_count: 0, fail_count: 0, total_count: 2 },
249/// };
250///
251/// let sensor_report = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
252///     .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
253///     .baseline(true, None)
254///     .artifact("report.json".to_string(), "sensor_report".to_string())
255///     .build(&report);
256///
257/// assert_eq!(sensor_report.verdict.status, perfgate_types::SensorVerdictStatus::Pass);
258/// assert_eq!(sensor_report.verdict.counts.info, 2);
259/// ```
260pub struct SensorReportBuilder {
261    tool: ToolInfo,
262    started_at: String,
263    ended_at: Option<String>,
264    duration_ms: Option<u64>,
265    baseline_available: bool,
266    baseline_reason: Option<String>,
267    engine_capability: Option<Capability>,
268    artifacts: Vec<SensorArtifact>,
269    max_findings: Option<usize>,
270}
271
272impl SensorReportBuilder {
273    /// Create a new SensorReportBuilder.
274    ///
275    /// # Examples
276    ///
277    /// ```
278    /// use perfgate_sensor::SensorReportBuilder;
279    /// use perfgate_types::ToolInfo;
280    ///
281    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
282    /// let builder = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string());
283    /// ```
284    pub fn new(tool: ToolInfo, started_at: String) -> Self {
285        Self {
286            tool,
287            started_at,
288            ended_at: None,
289            duration_ms: None,
290            baseline_available: false,
291            baseline_reason: None,
292            engine_capability: Some(default_engine_capability()),
293            artifacts: Vec::new(),
294            max_findings: None,
295        }
296    }
297
298    /// Set the end time and duration.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// use perfgate_sensor::SensorReportBuilder;
304    /// use perfgate_types::ToolInfo;
305    ///
306    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
307    /// let builder = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
308    ///     .ended_at("2024-01-01T00:01:00Z".to_string(), 60000);
309    /// ```
310    pub fn ended_at(mut self, ended_at: String, duration_ms: u64) -> Self {
311        self.ended_at = Some(ended_at);
312        self.duration_ms = Some(duration_ms);
313        self
314    }
315
316    /// Set baseline availability.
317    ///
318    /// # Examples
319    ///
320    /// ```
321    /// use perfgate_sensor::SensorReportBuilder;
322    /// use perfgate_types::ToolInfo;
323    ///
324    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
325    /// // Baseline available
326    /// let builder = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
327    ///     .baseline(true, None);
328    /// ```
329    pub fn baseline(mut self, available: bool, reason: Option<String>) -> Self {
330        self.baseline_available = available;
331        self.baseline_reason = reason;
332        self
333    }
334
335    /// Set engine capability explicitly.
336    ///
337    /// # Examples
338    ///
339    /// ```
340    /// use perfgate_sensor::SensorReportBuilder;
341    /// use perfgate_types::{ToolInfo, Capability, CapabilityStatus};
342    ///
343    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
344    /// let builder = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
345    ///     .engine(Capability {
346    ///         status: CapabilityStatus::Available,
347    ///         reason: None,
348    ///     });
349    /// ```
350    pub fn engine(mut self, capability: Capability) -> Self {
351        self.engine_capability = Some(capability);
352        self
353    }
354
355    /// Add an artifact.
356    ///
357    /// # Examples
358    ///
359    /// ```
360    /// use perfgate_sensor::SensorReportBuilder;
361    /// use perfgate_types::ToolInfo;
362    ///
363    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
364    /// let builder = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
365    ///     .artifact("report.json".to_string(), "sensor_report".to_string());
366    /// ```
367    pub fn artifact(mut self, path: String, artifact_type: String) -> Self {
368        self.artifacts.push(SensorArtifact {
369            path,
370            artifact_type,
371        });
372        self
373    }
374
375    /// Set the maximum number of findings to include.
376    /// When exceeded, findings are truncated and a meta-finding is appended.
377    /// `findings_emitted` in the output counts real findings only (excluding the truncation meta-finding).
378    ///
379    /// # Examples
380    ///
381    /// ```
382    /// use perfgate_sensor::SensorReportBuilder;
383    /// use perfgate_types::ToolInfo;
384    ///
385    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
386    /// let builder = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
387    ///     .max_findings(50);
388    /// ```
389    pub fn max_findings(mut self, limit: usize) -> Self {
390        self.max_findings = Some(limit);
391        self
392    }
393
394    /// Take ownership of accumulated artifacts (for manual report building).
395    ///
396    /// # Examples
397    ///
398    /// ```
399    /// use perfgate_sensor::SensorReportBuilder;
400    /// use perfgate_types::ToolInfo;
401    ///
402    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
403    /// let mut builder = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
404    ///     .artifact("report.json".to_string(), "sensor_report".to_string());
405    /// let artifacts = builder.take_artifacts();
406    /// assert_eq!(artifacts.len(), 1);
407    /// assert_eq!(artifacts[0].path, "report.json");
408    /// ```
409    pub fn take_artifacts(&mut self) -> Vec<SensorArtifact> {
410        std::mem::take(&mut self.artifacts)
411    }
412
413    /// Build the SensorReport from a PerfgateReport.
414    ///
415    /// # Examples
416    ///
417    /// ```
418    /// use perfgate_sensor::SensorReportBuilder;
419    /// use perfgate_types::{
420    ///     ToolInfo, PerfgateReport, VerdictStatus, Verdict, VerdictCounts,
421    ///     ReportSummary, SensorVerdictStatus, REPORT_SCHEMA_V1,
422    /// };
423    ///
424    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
425    /// let report = PerfgateReport {
426    ///     report_type: REPORT_SCHEMA_V1.to_string(),
427    ///     verdict: Verdict {
428    ///         status: VerdictStatus::Pass,
429    ///         counts: VerdictCounts { pass: 1, warn: 0, fail: 0 },
430    ///         reasons: vec![],
431    ///     },
432    ///     compare: None,
433    ///     findings: vec![],
434    ///     summary: ReportSummary {
435    ///         pass_count: 1, warn_count: 0, fail_count: 0, total_count: 1,
436    ///     },
437    /// };
438    ///
439    /// let sensor = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
440    ///     .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
441    ///     .baseline(true, None)
442    ///     .build(&report);
443    ///
444    /// assert_eq!(sensor.verdict.status, SensorVerdictStatus::Pass);
445    /// assert_eq!(sensor.verdict.counts.info, 1);
446    /// ```
447    pub fn build(mut self, report: &PerfgateReport) -> SensorReport {
448        let status = match report.verdict.status {
449            VerdictStatus::Pass => SensorVerdictStatus::Pass,
450            VerdictStatus::Warn => SensorVerdictStatus::Warn,
451            VerdictStatus::Fail => SensorVerdictStatus::Fail,
452        };
453
454        let counts = SensorVerdictCounts {
455            info: report.summary.pass_count,
456            warn: report.summary.warn_count,
457            error: report.summary.fail_count,
458        };
459
460        let mut reasons = report.verdict.reasons.clone();
461
462        let mut findings: Vec<SensorFinding> = report
463            .findings
464            .iter()
465            .map(|f| {
466                let metric_name = f
467                    .data
468                    .as_ref()
469                    .map(|d| d.metric_name.as_str())
470                    .unwrap_or("");
471                SensorFinding {
472                    check_id: f.check_id.clone(),
473                    code: f.code.clone(),
474                    severity: match f.severity {
475                        Severity::Warn => SensorSeverity::Warn,
476                        Severity::Fail => SensorSeverity::Error,
477                    },
478                    message: f.message.clone(),
479                    fingerprint: Some(sensor_fingerprint(&[
480                        &self.tool.name,
481                        &f.check_id,
482                        &f.code,
483                        metric_name,
484                    ])),
485                    data: f.data.as_ref().and_then(|d| serde_json::to_value(d).ok()),
486                }
487            })
488            .collect();
489
490        let truncation_totals = if let Some(limit) = self.max_findings {
491            truncate_findings(&mut findings, &mut reasons, limit, &self.tool.name)
492        } else {
493            None
494        };
495
496        let verdict = SensorVerdict {
497            status,
498            counts,
499            reasons,
500        };
501
502        let capabilities = SensorCapabilities {
503            baseline: Capability {
504                status: if self.baseline_available {
505                    CapabilityStatus::Available
506                } else {
507                    CapabilityStatus::Unavailable
508                },
509                reason: self.baseline_reason,
510            },
511            engine: self.engine_capability,
512        };
513
514        let run = SensorRunMeta {
515            started_at: self.started_at,
516            ended_at: self.ended_at,
517            duration_ms: self.duration_ms,
518            capabilities,
519        };
520
521        let mut data = serde_json::json!({
522            "summary": {
523                "pass_count": report.summary.pass_count,
524                "warn_count": report.summary.warn_count,
525                "fail_count": report.summary.fail_count,
526                "total_count": report.summary.total_count,
527                "bench_count": 1,
528            }
529        });
530
531        if let Some((total, emitted)) = truncation_totals {
532            data["findings_total"] = serde_json::json!(total);
533            data["findings_emitted"] = serde_json::json!(emitted);
534        }
535
536        self.artifacts
537            .sort_by(|a, b| (&a.artifact_type, &a.path).cmp(&(&b.artifact_type, &b.path)));
538
539        SensorReport {
540            schema: SENSOR_REPORT_SCHEMA_V1.to_string(),
541            tool: self.tool,
542            run,
543            verdict,
544            findings,
545            artifacts: self.artifacts,
546            data,
547        }
548    }
549
550    /// Build an error SensorReport for catastrophic failures.
551    ///
552    /// This creates a report when the sensor itself failed to run properly.
553    /// `stage` indicates which phase failed (e.g. "config_parse", "run_command").
554    /// `error_kind` classifies the error (e.g. "io_error", "parse_error", "exec_error").
555    ///
556    /// # Examples
557    ///
558    /// ```
559    /// use perfgate_sensor::SensorReportBuilder;
560    /// use perfgate_types::{ToolInfo, SensorVerdictStatus};
561    ///
562    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
563    /// let sensor = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
564    ///     .build_error("config not found", "config_parse", "parse_error");
565    ///
566    /// assert_eq!(sensor.verdict.status, SensorVerdictStatus::Fail);
567    /// assert_eq!(sensor.verdict.counts.error, 1);
568    /// assert_eq!(sensor.findings.len(), 1);
569    /// ```
570    pub fn build_error(
571        mut self,
572        error_message: &str,
573        stage: &str,
574        error_kind: &str,
575    ) -> SensorReport {
576        let verdict = SensorVerdict {
577            status: SensorVerdictStatus::Fail,
578            counts: SensorVerdictCounts {
579                info: 0,
580                warn: 0,
581                error: 1,
582            },
583            reasons: vec![VERDICT_REASON_TOOL_ERROR.to_string()],
584        };
585
586        let finding = SensorFinding {
587            check_id: CHECK_ID_TOOL_RUNTIME.to_string(),
588            code: FINDING_CODE_RUNTIME_ERROR.to_string(),
589            severity: SensorSeverity::Error,
590            message: error_message.to_string(),
591            fingerprint: Some(sensor_fingerprint(&[
592                &self.tool.name,
593                CHECK_ID_TOOL_RUNTIME,
594                FINDING_CODE_RUNTIME_ERROR,
595                stage,
596                error_kind,
597            ])),
598            data: Some(serde_json::json!({
599                "stage": stage,
600                "error_kind": error_kind,
601            })),
602        };
603
604        let capabilities = SensorCapabilities {
605            baseline: Capability {
606                status: if self.baseline_available {
607                    CapabilityStatus::Available
608                } else {
609                    CapabilityStatus::Unavailable
610                },
611                reason: self.baseline_reason,
612            },
613            engine: self.engine_capability,
614        };
615
616        let run = SensorRunMeta {
617            started_at: self.started_at,
618            ended_at: self.ended_at,
619            duration_ms: self.duration_ms,
620            capabilities,
621        };
622
623        let data = serde_json::json!({
624            "summary": {
625                "pass_count": 0,
626                "warn_count": 0,
627                "fail_count": 1,
628                "total_count": 1,
629                "bench_count": 0,
630            }
631        });
632
633        self.artifacts
634            .sort_by(|a, b| (&a.artifact_type, &a.path).cmp(&(&b.artifact_type, &b.path)));
635
636        SensorReport {
637            schema: SENSOR_REPORT_SCHEMA_V1.to_string(),
638            tool: self.tool,
639            run,
640            verdict,
641            findings: vec![finding],
642            artifacts: self.artifacts,
643            data,
644        }
645    }
646
647    /// Build an aggregated SensorReport from multiple bench outcomes.
648    ///
649    /// This encapsulates the multi-bench cockpit aggregation logic:
650    /// - Maps findings from each bench's `PerfgateReport` to sensor findings
651    /// - In multi-bench mode: prefixes messages with `[bench_name]`, injects
652    ///   `bench_name` into finding data, includes bench_name in fingerprint seed
653    /// - Aggregates counts (sum), verdict (worst-of), reasons (union/deduped)
654    /// - Registers per-bench artifacts
655    /// - Combines markdown with `\n---\n\n` separator in multi-bench
656    /// - Applies truncation via `truncate_findings()` using `self.max_findings`
657    /// - Returns `(SensorReport, combined_markdown)`
658    ///
659    /// # Examples
660    ///
661    /// ```
662    /// use perfgate_sensor::{SensorReportBuilder, BenchOutcome};
663    /// use perfgate_types::{
664    ///     ToolInfo, PerfgateReport, VerdictStatus, Verdict, VerdictCounts,
665    ///     ReportSummary, SensorVerdictStatus, REPORT_SCHEMA_V1,
666    /// };
667    ///
668    /// let tool = ToolInfo { name: "perfgate".to_string(), version: "0.1.0".to_string() };
669    /// let report = PerfgateReport {
670    ///     report_type: REPORT_SCHEMA_V1.to_string(),
671    ///     verdict: Verdict {
672    ///         status: VerdictStatus::Pass,
673    ///         counts: VerdictCounts { pass: 1, warn: 0, fail: 0 },
674    ///         reasons: vec![],
675    ///     },
676    ///     compare: None,
677    ///     findings: vec![],
678    ///     summary: ReportSummary {
679    ///         pass_count: 1, warn_count: 0, fail_count: 0, total_count: 1,
680    ///     },
681    /// };
682    ///
683    /// let outcome = BenchOutcome::Success {
684    ///     bench_name: "my-bench".to_string(),
685    ///     report,
686    ///     has_compare: false,
687    ///     baseline_available: false,
688    ///     markdown: "## Results\n".to_string(),
689    ///     extras_prefix: "extras".to_string(),
690    /// };
691    ///
692    /// let (sensor, markdown) = SensorReportBuilder::new(tool, "2024-01-01T00:00:00Z".to_string())
693    ///     .build_aggregated(&[outcome]);
694    ///
695    /// assert_eq!(sensor.verdict.status, SensorVerdictStatus::Pass);
696    /// assert!(markdown.contains("Results"));
697    /// ```
698    pub fn build_aggregated(mut self, outcomes: &[BenchOutcome]) -> (SensorReport, String) {
699        let multi_bench = outcomes.len() > 1;
700
701        let mut aggregated_findings: Vec<SensorFinding> = Vec::new();
702        let mut total_info = 0u32;
703        let mut total_warn = 0u32;
704        let mut total_error = 0u32;
705        let mut worst_status = SensorVerdictStatus::Pass;
706        let mut all_reasons: Vec<String> = Vec::new();
707        let mut combined_markdown = String::new();
708
709        for outcome in outcomes {
710            match outcome {
711                BenchOutcome::Success {
712                    bench_name,
713                    report,
714                    has_compare,
715                    baseline_available,
716                    markdown,
717                    extras_prefix,
718                } => {
719                    for f in &report.findings {
720                        let severity = match f.severity {
721                            Severity::Warn => SensorSeverity::Warn,
722                            Severity::Fail => SensorSeverity::Error,
723                        };
724                        let mut finding_data =
725                            f.data.as_ref().and_then(|d| serde_json::to_value(d).ok());
726                        if multi_bench {
727                            if let Some(val) = &mut finding_data {
728                                if let Some(obj) = val.as_object_mut() {
729                                    obj.insert(
730                                        "bench_name".to_string(),
731                                        serde_json::Value::String(bench_name.clone()),
732                                    );
733                                }
734                            } else {
735                                finding_data =
736                                    Some(serde_json::json!({ "bench_name": bench_name }));
737                            }
738                        }
739                        let metric_name = f
740                            .data
741                            .as_ref()
742                            .map(|d| d.metric_name.as_str())
743                            .unwrap_or("");
744                        let fingerprint = if multi_bench {
745                            Some(sensor_fingerprint(&[
746                                &self.tool.name,
747                                bench_name,
748                                &f.check_id,
749                                &f.code,
750                                metric_name,
751                            ]))
752                        } else {
753                            Some(sensor_fingerprint(&[
754                                &self.tool.name,
755                                &f.check_id,
756                                &f.code,
757                                metric_name,
758                            ]))
759                        };
760                        aggregated_findings.push(SensorFinding {
761                            check_id: f.check_id.clone(),
762                            code: f.code.clone(),
763                            severity,
764                            message: if multi_bench {
765                                format!("[{}] {}", bench_name, f.message)
766                            } else {
767                                f.message.clone()
768                            },
769                            fingerprint,
770                            data: finding_data,
771                        });
772                    }
773
774                    total_info += report.summary.pass_count;
775                    total_warn += report.summary.warn_count;
776                    total_error += report.summary.fail_count;
777
778                    match report.verdict.status {
779                        VerdictStatus::Fail => {
780                            worst_status = SensorVerdictStatus::Fail;
781                        }
782                        VerdictStatus::Warn => {
783                            if worst_status != SensorVerdictStatus::Fail {
784                                worst_status = SensorVerdictStatus::Warn;
785                            }
786                        }
787                        VerdictStatus::Pass => {}
788                    }
789
790                    for reason in &report.verdict.reasons {
791                        if !all_reasons.contains(reason) {
792                            all_reasons.push(reason.clone());
793                        }
794                    }
795
796                    self.artifacts.push(SensorArtifact {
797                        path: format!("{}/perfgate.run.v1.json", extras_prefix),
798                        artifact_type: "run_receipt".to_string(),
799                    });
800                    self.artifacts.push(SensorArtifact {
801                        path: format!("{}/perfgate.report.v1.json", extras_prefix),
802                        artifact_type: "perfgate_report".to_string(),
803                    });
804                    if *has_compare {
805                        self.artifacts.push(SensorArtifact {
806                            path: format!("{}/perfgate.compare.v1.json", extras_prefix),
807                            artifact_type: "compare_receipt".to_string(),
808                        });
809                    }
810
811                    if multi_bench && !combined_markdown.is_empty() {
812                        combined_markdown.push_str("\n---\n\n");
813                    }
814                    combined_markdown.push_str(markdown);
815
816                    if !baseline_available
817                        && !all_reasons.contains(&BASELINE_REASON_NO_BASELINE.to_string())
818                    {
819                        all_reasons.push(BASELINE_REASON_NO_BASELINE.to_string());
820                    }
821                }
822
823                BenchOutcome::Error {
824                    bench_name,
825                    error_message,
826                    stage,
827                    error_kind,
828                } => {
829                    let mut finding_data = serde_json::json!({
830                        "stage": stage,
831                        "error_kind": error_kind,
832                    });
833                    if multi_bench {
834                        finding_data
835                            .as_object_mut()
836                            .unwrap()
837                            .insert("bench_name".to_string(), serde_json::json!(bench_name));
838                    }
839                    let fingerprint = Some(sensor_fingerprint(&[
840                        &self.tool.name,
841                        bench_name,
842                        CHECK_ID_TOOL_RUNTIME,
843                        FINDING_CODE_RUNTIME_ERROR,
844                        stage,
845                    ]));
846                    let message = if multi_bench {
847                        format!("[{}] {}", bench_name, error_message)
848                    } else {
849                        error_message.clone()
850                    };
851                    aggregated_findings.push(SensorFinding {
852                        check_id: CHECK_ID_TOOL_RUNTIME.to_string(),
853                        code: FINDING_CODE_RUNTIME_ERROR.to_string(),
854                        severity: SensorSeverity::Error,
855                        message,
856                        fingerprint,
857                        data: Some(finding_data),
858                    });
859
860                    total_error += 1;
861                    worst_status = SensorVerdictStatus::Fail;
862                    if !all_reasons.contains(&VERDICT_REASON_TOOL_ERROR.to_string()) {
863                        all_reasons.push(VERDICT_REASON_TOOL_ERROR.to_string());
864                    }
865
866                    if multi_bench && !combined_markdown.is_empty() {
867                        combined_markdown.push_str("\n---\n\n");
868                    }
869                    combined_markdown.push_str(&format!(
870                        "## {}\n\n**Error:** {}\n",
871                        bench_name, error_message
872                    ));
873
874                    if *stage == perfgate_types::STAGE_BASELINE_RESOLVE
875                        && !all_reasons.contains(&BASELINE_REASON_NO_BASELINE.to_string())
876                    {
877                        all_reasons.push(BASELINE_REASON_NO_BASELINE.to_string());
878                    }
879                }
880            }
881        }
882
883        self.artifacts.push(SensorArtifact {
884            path: "comment.md".to_string(),
885            artifact_type: "markdown".to_string(),
886        });
887
888        let limit = self.max_findings.unwrap_or(MAX_FINDINGS_DEFAULT);
889        let truncation_totals = truncate_findings(
890            &mut aggregated_findings,
891            &mut all_reasons,
892            limit,
893            &self.tool.name,
894        );
895
896        let mut data = serde_json::json!({
897            "summary": {
898                "pass_count": total_info,
899                "warn_count": total_warn,
900                "fail_count": total_error,
901                "total_count": total_info + total_warn + total_error,
902                "bench_count": outcomes.len(),
903            }
904        });
905
906        if let Some((total, emitted)) = truncation_totals {
907            data["findings_total"] = serde_json::json!(total);
908            data["findings_emitted"] = serde_json::json!(emitted);
909        }
910
911        let any_baseline_available = outcomes.iter().any(|o| {
912            matches!(
913                o,
914                BenchOutcome::Success {
915                    baseline_available: true,
916                    ..
917                }
918            )
919        });
920        let all_baseline_available = outcomes.iter().all(|o| {
921            matches!(
922                o,
923                BenchOutcome::Success {
924                    baseline_available: true,
925                    ..
926                }
927            )
928        });
929
930        let capabilities = SensorCapabilities {
931            baseline: Capability {
932                status: if all_baseline_available {
933                    CapabilityStatus::Available
934                } else {
935                    CapabilityStatus::Unavailable
936                },
937                reason: if !any_baseline_available {
938                    self.baseline_reason
939                        .clone()
940                        .or(Some(BASELINE_REASON_NO_BASELINE.to_string()))
941                } else {
942                    None
943                },
944            },
945            engine: self.engine_capability,
946        };
947
948        let run = SensorRunMeta {
949            started_at: self.started_at,
950            ended_at: self.ended_at,
951            duration_ms: self.duration_ms,
952            capabilities,
953        };
954
955        let verdict = SensorVerdict {
956            status: worst_status,
957            counts: SensorVerdictCounts {
958                info: total_info,
959                warn: total_warn,
960                error: total_error,
961            },
962            reasons: all_reasons,
963        };
964
965        self.artifacts
966            .sort_by(|a, b| (&a.artifact_type, &a.path).cmp(&(&b.artifact_type, &b.path)));
967
968        let sensor_report = SensorReport {
969            schema: SENSOR_REPORT_SCHEMA_V1.to_string(),
970            tool: self.tool,
971            run,
972            verdict,
973            findings: aggregated_findings,
974            artifacts: self.artifacts,
975            data,
976        };
977
978        (sensor_report, combined_markdown)
979    }
980}
981
982#[cfg(test)]
983mod tests {
984    use super::*;
985    use perfgate_types::{
986        FINDING_CODE_METRIC_FAIL, FINDING_CODE_METRIC_WARN, REPORT_SCHEMA_V1, ReportFinding,
987        ReportSummary, Verdict, VerdictCounts,
988    };
989
990    fn make_tool_info() -> ToolInfo {
991        ToolInfo {
992            name: "perfgate".to_string(),
993            version: "0.1.0".to_string(),
994        }
995    }
996
997    fn make_pass_report() -> PerfgateReport {
998        PerfgateReport {
999            report_type: REPORT_SCHEMA_V1.to_string(),
1000            verdict: Verdict {
1001                status: VerdictStatus::Pass,
1002                counts: VerdictCounts {
1003                    pass: 2,
1004                    warn: 0,
1005                    fail: 0,
1006                },
1007                reasons: vec![],
1008            },
1009            compare: None,
1010            findings: vec![],
1011            summary: ReportSummary {
1012                pass_count: 2,
1013                warn_count: 0,
1014                fail_count: 0,
1015                total_count: 2,
1016            },
1017        }
1018    }
1019
1020    fn make_fail_report() -> PerfgateReport {
1021        PerfgateReport {
1022            report_type: REPORT_SCHEMA_V1.to_string(),
1023            verdict: Verdict {
1024                status: VerdictStatus::Fail,
1025                counts: VerdictCounts {
1026                    pass: 1,
1027                    warn: 0,
1028                    fail: 1,
1029                },
1030                reasons: vec!["wall_ms_fail".to_string()],
1031            },
1032            compare: None,
1033            findings: vec![ReportFinding {
1034                check_id: "perf.budget".to_string(),
1035                code: FINDING_CODE_METRIC_FAIL.to_string(),
1036                severity: Severity::Fail,
1037                message: "wall_ms regression: +25.00% (threshold: 20.0%)".to_string(),
1038                data: None,
1039            }],
1040            summary: ReportSummary {
1041                pass_count: 1,
1042                warn_count: 0,
1043                fail_count: 1,
1044                total_count: 2,
1045            },
1046        }
1047    }
1048
1049    fn make_warn_report() -> PerfgateReport {
1050        PerfgateReport {
1051            report_type: REPORT_SCHEMA_V1.to_string(),
1052            verdict: Verdict {
1053                status: VerdictStatus::Warn,
1054                counts: VerdictCounts {
1055                    pass: 1,
1056                    warn: 1,
1057                    fail: 0,
1058                },
1059                reasons: vec!["wall_ms_warn".to_string()],
1060            },
1061            compare: None,
1062            findings: vec![ReportFinding {
1063                check_id: "perf.budget".to_string(),
1064                code: FINDING_CODE_METRIC_WARN.to_string(),
1065                severity: Severity::Warn,
1066                message: "wall_ms regression: +15.00% (threshold: 20.0%)".to_string(),
1067                data: None,
1068            }],
1069            summary: ReportSummary {
1070                pass_count: 1,
1071                warn_count: 1,
1072                fail_count: 0,
1073                total_count: 2,
1074            },
1075        }
1076    }
1077
1078    #[test]
1079    fn test_build_pass_sensor_report() {
1080        let report = make_pass_report();
1081        let builder =
1082            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1083                .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
1084                .baseline(true, None);
1085
1086        let sensor_report = builder.build(&report);
1087
1088        assert_eq!(sensor_report.schema, SENSOR_REPORT_SCHEMA_V1);
1089        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Pass);
1090        assert_eq!(sensor_report.verdict.counts.info, 2);
1091        assert_eq!(sensor_report.verdict.counts.warn, 0);
1092        assert_eq!(sensor_report.verdict.counts.error, 0);
1093        assert!(sensor_report.findings.is_empty());
1094        assert_eq!(
1095            sensor_report.run.capabilities.baseline.status,
1096            CapabilityStatus::Available
1097        );
1098        assert!(sensor_report.data.get("summary").is_some());
1099        assert!(sensor_report.data.get("compare").is_none());
1100    }
1101
1102    #[test]
1103    fn test_build_fail_sensor_report() {
1104        let report = make_fail_report();
1105        let builder =
1106            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1107                .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
1108                .baseline(true, None);
1109
1110        let sensor_report = builder.build(&report);
1111
1112        assert_eq!(sensor_report.schema, SENSOR_REPORT_SCHEMA_V1);
1113        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Fail);
1114        assert_eq!(sensor_report.verdict.counts.error, 1);
1115        assert_eq!(sensor_report.findings.len(), 1);
1116        assert_eq!(sensor_report.findings[0].severity, SensorSeverity::Error);
1117    }
1118
1119    #[test]
1120    fn test_build_warn_sensor_report() {
1121        let report = make_warn_report();
1122        let builder =
1123            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1124                .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
1125                .baseline(true, None);
1126
1127        let sensor_report = builder.build(&report);
1128
1129        assert_eq!(sensor_report.schema, SENSOR_REPORT_SCHEMA_V1);
1130        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Warn);
1131        assert_eq!(sensor_report.verdict.counts.warn, 1);
1132        assert_eq!(sensor_report.findings.len(), 1);
1133        assert_eq!(sensor_report.findings[0].severity, SensorSeverity::Warn);
1134    }
1135
1136    #[test]
1137    fn test_build_with_no_baseline() {
1138        let report = make_pass_report();
1139        let builder =
1140            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1141                .baseline(false, Some("baseline.json not found".to_string()));
1142
1143        let sensor_report = builder.build(&report);
1144
1145        assert_eq!(
1146            sensor_report.run.capabilities.baseline.status,
1147            CapabilityStatus::Unavailable
1148        );
1149        assert_eq!(
1150            sensor_report.run.capabilities.baseline.reason,
1151            Some("baseline.json not found".to_string())
1152        );
1153    }
1154
1155    #[test]
1156    fn test_build_with_artifacts_sorted() {
1157        let report = make_pass_report();
1158        let builder =
1159            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1160                .artifact(
1161                    "extras/perfgate.run.v1.json".to_string(),
1162                    "run_receipt".to_string(),
1163                )
1164                .artifact("comment.md".to_string(), "markdown".to_string());
1165
1166        let sensor_report = builder.build(&report);
1167
1168        assert_eq!(sensor_report.artifacts.len(), 2);
1169        assert_eq!(sensor_report.artifacts[0].artifact_type, "markdown");
1170        assert_eq!(sensor_report.artifacts[1].artifact_type, "run_receipt");
1171    }
1172
1173    #[test]
1174    fn test_build_error_report() {
1175        let builder =
1176            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1177                .ended_at("2024-01-01T00:00:01Z".to_string(), 1000)
1178                .baseline(false, None);
1179
1180        let sensor_report = builder.build_error(
1181            "config file not found",
1182            perfgate_types::STAGE_CONFIG_PARSE,
1183            perfgate_types::ERROR_KIND_PARSE,
1184        );
1185
1186        assert_eq!(sensor_report.schema, SENSOR_REPORT_SCHEMA_V1);
1187        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Fail);
1188        assert_eq!(sensor_report.verdict.counts.error, 1);
1189        assert_eq!(sensor_report.verdict.reasons, vec!["tool_error"]);
1190        assert_eq!(sensor_report.findings.len(), 1);
1191        assert_eq!(sensor_report.findings[0].check_id, "tool.runtime");
1192        assert_eq!(sensor_report.findings[0].code, "runtime_error");
1193        assert!(
1194            sensor_report.findings[0]
1195                .message
1196                .contains("config file not found")
1197        );
1198        let data = sensor_report.findings[0].data.as_ref().unwrap();
1199        assert_eq!(data["stage"], "config_parse");
1200        assert_eq!(data["error_kind"], "parse_error");
1201    }
1202
1203    #[test]
1204    fn test_fingerprint_format_for_metric_finding() {
1205        let report = make_fail_report();
1206        let builder =
1207            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1208                .baseline(true, None);
1209
1210        let sensor_report = builder.build(&report);
1211
1212        assert_eq!(sensor_report.findings.len(), 1);
1213        assert_eq!(
1214            sensor_report.findings[0].fingerprint,
1215            Some(sensor_fingerprint(&[
1216                "perfgate",
1217                "perf.budget",
1218                "metric_fail",
1219                ""
1220            ]))
1221        );
1222    }
1223
1224    #[test]
1225    fn test_fingerprint_format_for_metric_finding_with_data() {
1226        use perfgate_types::{Direction, FindingData};
1227
1228        let report = PerfgateReport {
1229            report_type: REPORT_SCHEMA_V1.to_string(),
1230            verdict: Verdict {
1231                status: VerdictStatus::Fail,
1232                counts: VerdictCounts {
1233                    pass: 0,
1234                    warn: 0,
1235                    fail: 1,
1236                },
1237                reasons: vec![],
1238            },
1239            compare: None,
1240            findings: vec![ReportFinding {
1241                check_id: "perf.budget".to_string(),
1242                code: FINDING_CODE_METRIC_FAIL.to_string(),
1243                severity: Severity::Fail,
1244                message: "wall_ms regression".to_string(),
1245                data: Some(FindingData {
1246                    metric_name: "wall_ms".to_string(),
1247                    baseline: 100.0,
1248                    current: 150.0,
1249                    regression_pct: 50.0,
1250                    threshold: 0.2,
1251                    direction: Direction::Lower,
1252                }),
1253            }],
1254            summary: ReportSummary {
1255                pass_count: 0,
1256                warn_count: 0,
1257                fail_count: 1,
1258                total_count: 1,
1259            },
1260        };
1261
1262        let builder =
1263            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1264                .baseline(true, None);
1265
1266        let sensor_report = builder.build(&report);
1267
1268        assert_eq!(
1269            sensor_report.findings[0].fingerprint,
1270            Some(sensor_fingerprint(&[
1271                "perfgate",
1272                "perf.budget",
1273                "metric_fail",
1274                "wall_ms"
1275            ]))
1276        );
1277    }
1278
1279    #[test]
1280    fn test_fingerprint_format_for_error_finding() {
1281        let builder =
1282            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1283                .baseline(false, None);
1284
1285        let sensor_report = builder.build_error(
1286            "config file not found",
1287            perfgate_types::STAGE_CONFIG_PARSE,
1288            perfgate_types::ERROR_KIND_PARSE,
1289        );
1290
1291        assert_eq!(
1292            sensor_report.findings[0].fingerprint,
1293            Some(sensor_fingerprint(&[
1294                "perfgate",
1295                "tool.runtime",
1296                "runtime_error",
1297                "config_parse",
1298                "parse_error"
1299            ]))
1300        );
1301    }
1302
1303    #[test]
1304    fn test_fingerprint_absent_when_no_findings() {
1305        let report = make_pass_report();
1306        let builder =
1307            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1308                .baseline(true, None);
1309
1310        let sensor_report = builder.build(&report);
1311
1312        assert!(sensor_report.findings.is_empty());
1313    }
1314
1315    #[test]
1316    fn test_truncation_not_applied_under_limit() {
1317        let report = make_fail_report();
1318        let builder =
1319            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1320                .baseline(true, None)
1321                .max_findings(100);
1322
1323        let sensor_report = builder.build(&report);
1324
1325        assert_eq!(sensor_report.findings.len(), 1);
1326        assert_ne!(sensor_report.findings[0].check_id, CHECK_ID_TOOL_TRUNCATION);
1327        assert!(
1328            !sensor_report
1329                .verdict
1330                .reasons
1331                .contains(&"truncated".to_string()),
1332            "verdict.reasons should NOT contain 'truncated' when under limit"
1333        );
1334    }
1335
1336    #[test]
1337    fn test_truncation_applied_at_limit() {
1338        use perfgate_types::FindingData;
1339
1340        let findings: Vec<ReportFinding> = (0..5)
1341            .map(|i| ReportFinding {
1342                check_id: "perf.budget".to_string(),
1343                code: FINDING_CODE_METRIC_FAIL.to_string(),
1344                severity: Severity::Fail,
1345                message: format!("metric {} regression", i),
1346                data: Some(FindingData {
1347                    metric_name: format!("metric_{}", i),
1348                    baseline: 100.0,
1349                    current: 150.0,
1350                    regression_pct: 50.0,
1351                    threshold: 0.2,
1352                    direction: perfgate_types::Direction::Lower,
1353                }),
1354            })
1355            .collect();
1356
1357        let report = PerfgateReport {
1358            report_type: REPORT_SCHEMA_V1.to_string(),
1359            verdict: Verdict {
1360                status: VerdictStatus::Fail,
1361                counts: VerdictCounts {
1362                    pass: 0,
1363                    warn: 0,
1364                    fail: 5,
1365                },
1366                reasons: vec![],
1367            },
1368            compare: None,
1369            findings,
1370            summary: ReportSummary {
1371                pass_count: 0,
1372                warn_count: 0,
1373                fail_count: 5,
1374                total_count: 5,
1375            },
1376        };
1377
1378        let builder =
1379            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1380                .baseline(true, None)
1381                .max_findings(3);
1382
1383        let sensor_report = builder.build(&report);
1384
1385        assert_eq!(sensor_report.findings.len(), 3);
1386
1387        let last = &sensor_report.findings[2];
1388        assert_eq!(last.check_id, CHECK_ID_TOOL_TRUNCATION);
1389        assert_eq!(last.code, FINDING_CODE_TRUNCATED);
1390        assert_eq!(last.severity, SensorSeverity::Info);
1391        assert_eq!(
1392            last.fingerprint,
1393            Some(sensor_fingerprint(&[
1394                "perfgate",
1395                "tool.truncation",
1396                "truncated"
1397            ]))
1398        );
1399
1400        let data = last.data.as_ref().unwrap();
1401        assert_eq!(data["total_findings"], 5);
1402        assert_eq!(data["shown_findings"], 2);
1403
1404        assert!(
1405            sensor_report
1406                .verdict
1407                .reasons
1408                .contains(&"truncated".to_string()),
1409            "verdict.reasons should contain 'truncated'"
1410        );
1411
1412        assert_eq!(sensor_report.data["findings_total"], 5);
1413        assert_eq!(sensor_report.data["findings_emitted"], 2);
1414    }
1415
1416    #[test]
1417    fn test_truncation_meta_finding_structure() {
1418        use perfgate_types::FindingData;
1419
1420        let findings: Vec<ReportFinding> = (0..10)
1421            .map(|i| ReportFinding {
1422                check_id: "perf.budget".to_string(),
1423                code: FINDING_CODE_METRIC_FAIL.to_string(),
1424                severity: Severity::Fail,
1425                message: format!("metric {} regression", i),
1426                data: Some(FindingData {
1427                    metric_name: format!("metric_{}", i),
1428                    baseline: 100.0,
1429                    current: 150.0,
1430                    regression_pct: 50.0,
1431                    threshold: 0.2,
1432                    direction: perfgate_types::Direction::Lower,
1433                }),
1434            })
1435            .collect();
1436
1437        let report = PerfgateReport {
1438            report_type: REPORT_SCHEMA_V1.to_string(),
1439            verdict: Verdict {
1440                status: VerdictStatus::Fail,
1441                counts: VerdictCounts {
1442                    pass: 0,
1443                    warn: 0,
1444                    fail: 10,
1445                },
1446                reasons: vec![],
1447            },
1448            compare: None,
1449            findings,
1450            summary: ReportSummary {
1451                pass_count: 0,
1452                warn_count: 0,
1453                fail_count: 10,
1454                total_count: 10,
1455            },
1456        };
1457
1458        let builder =
1459            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1460                .baseline(true, None)
1461                .max_findings(5);
1462
1463        let sensor_report = builder.build(&report);
1464
1465        assert_eq!(sensor_report.findings.len(), 5);
1466
1467        let meta = &sensor_report.findings[4];
1468        assert!(meta.message.contains("Showing 4 of 10"));
1469        assert!(meta.message.contains("6 omitted"));
1470
1471        assert!(
1472            sensor_report
1473                .verdict
1474                .reasons
1475                .contains(&"truncated".to_string()),
1476            "verdict.reasons should contain 'truncated'"
1477        );
1478
1479        assert_eq!(sensor_report.data["findings_total"], 10);
1480        assert_eq!(sensor_report.data["findings_emitted"], 4);
1481    }
1482
1483    #[test]
1484    fn test_sensor_report_serialization_round_trip() {
1485        let report = make_fail_report();
1486        let builder =
1487            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1488                .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
1489                .baseline(true, None)
1490                .artifact("report.json".to_string(), "sensor_report".to_string());
1491
1492        let sensor_report = builder.build(&report);
1493
1494        let json = serde_json::to_string(&sensor_report).expect("should serialize");
1495
1496        let deserialized: SensorReport = serde_json::from_str(&json).expect("should deserialize");
1497
1498        assert_eq!(sensor_report, deserialized);
1499    }
1500
1501    #[test]
1502    fn test_build_error_report_for_invalid_bench_name() {
1503        let builder =
1504            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1505                .ended_at("2024-01-01T00:00:01Z".to_string(), 1000)
1506                .baseline(false, None);
1507
1508        let msg =
1509            "bench name \"../evil\" contains a \"..\" path segment (path traversal is forbidden)";
1510        let sensor_report = builder.build_error(
1511            msg,
1512            perfgate_types::STAGE_CONFIG_PARSE,
1513            perfgate_types::ERROR_KIND_PARSE,
1514        );
1515
1516        assert_eq!(sensor_report.schema, SENSOR_REPORT_SCHEMA_V1);
1517        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Fail);
1518        assert_eq!(sensor_report.verdict.counts.error, 1);
1519        assert_eq!(sensor_report.findings.len(), 1);
1520        assert_eq!(sensor_report.findings[0].check_id, "tool.runtime");
1521        assert_eq!(sensor_report.findings[0].code, "runtime_error");
1522        let data = sensor_report.findings[0].data.as_ref().unwrap();
1523        assert_eq!(data["stage"], "config_parse");
1524        assert_eq!(data["error_kind"], "parse_error");
1525    }
1526
1527    // --- Engine capability tests ---
1528
1529    #[test]
1530    fn test_build_pass_sensor_report_has_engine_capability() {
1531        let report = make_pass_report();
1532        let builder =
1533            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1534                .baseline(true, None);
1535
1536        let sensor_report = builder.build(&report);
1537
1538        assert!(
1539            sensor_report.run.capabilities.engine.is_some(),
1540            "engine capability should be present"
1541        );
1542    }
1543
1544    #[test]
1545    fn test_build_error_report_has_engine_capability() {
1546        let builder =
1547            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1548                .baseline(false, None);
1549
1550        let sensor_report = builder.build_error(
1551            "config file not found",
1552            perfgate_types::STAGE_CONFIG_PARSE,
1553            perfgate_types::ERROR_KIND_PARSE,
1554        );
1555
1556        assert!(
1557            sensor_report.run.capabilities.engine.is_some(),
1558            "engine capability should be present in error report"
1559        );
1560    }
1561
1562    #[test]
1563    fn test_engine_capability_explicit_override() {
1564        let report = make_pass_report();
1565        let builder =
1566            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1567                .baseline(true, None)
1568                .engine(Capability {
1569                    status: CapabilityStatus::Unavailable,
1570                    reason: Some("platform_limited".to_string()),
1571                });
1572
1573        let sensor_report = builder.build(&report);
1574
1575        let engine = sensor_report.run.capabilities.engine.unwrap();
1576        assert_eq!(engine.status, CapabilityStatus::Unavailable);
1577        assert_eq!(engine.reason, Some("platform_limited".to_string()));
1578    }
1579
1580    #[test]
1581    fn test_default_engine_capability_value() {
1582        let cap = default_engine_capability();
1583        if cfg!(unix) {
1584            assert_eq!(cap.status, CapabilityStatus::Available);
1585            assert!(cap.reason.is_none());
1586        } else {
1587            assert_eq!(cap.status, CapabilityStatus::Unavailable);
1588            assert_eq!(cap.reason, Some("platform_limited".to_string()));
1589        }
1590    }
1591
1592    // --- build_aggregated() tests ---
1593
1594    fn make_bench_outcome(
1595        bench_name: &str,
1596        report: PerfgateReport,
1597        has_compare: bool,
1598        baseline_available: bool,
1599        extras_prefix: &str,
1600    ) -> BenchOutcome {
1601        BenchOutcome::Success {
1602            bench_name: bench_name.to_string(),
1603            report,
1604            has_compare,
1605            baseline_available,
1606            markdown: format!("## {}\n\nSome results\n", bench_name),
1607            extras_prefix: extras_prefix.to_string(),
1608        }
1609    }
1610
1611    fn make_error_outcome(
1612        bench_name: &str,
1613        error_message: &str,
1614        stage: &'static str,
1615        error_kind: &'static str,
1616    ) -> BenchOutcome {
1617        BenchOutcome::Error {
1618            bench_name: bench_name.to_string(),
1619            error_message: error_message.to_string(),
1620            stage,
1621            error_kind,
1622        }
1623    }
1624
1625    #[test]
1626    fn test_build_aggregated_single_bench_matches_build() {
1627        let report = make_fail_report();
1628        let outcome = make_bench_outcome("my-bench", report.clone(), true, true, "extras");
1629
1630        let builder =
1631            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1632                .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
1633                .baseline(true, None);
1634
1635        let (sensor_report, _md) = builder.build_aggregated(&[outcome]);
1636
1637        assert_eq!(sensor_report.findings.len(), 1);
1638        assert!(
1639            !sensor_report.findings[0].message.starts_with("[my-bench]"),
1640            "single bench findings should not be prefixed"
1641        );
1642        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Fail);
1643        assert_eq!(sensor_report.verdict.counts.error, 1);
1644    }
1645
1646    #[test]
1647    fn test_build_aggregated_multi_bench_findings_prefixed() {
1648        let report_a = make_fail_report();
1649        let report_b = make_warn_report();
1650        let outcome_a = make_bench_outcome("bench-a", report_a, true, true, "extras/bench-a");
1651        let outcome_b = make_bench_outcome("bench-b", report_b, true, true, "extras/bench-b");
1652
1653        let builder =
1654            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1655                .ended_at("2024-01-01T00:01:00Z".to_string(), 60000);
1656
1657        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1658
1659        assert_eq!(sensor_report.findings.len(), 2);
1660        assert!(
1661            sensor_report.findings[0].message.starts_with("[bench-a]"),
1662            "multi-bench findings should be prefixed: {}",
1663            sensor_report.findings[0].message
1664        );
1665        assert!(
1666            sensor_report.findings[1].message.starts_with("[bench-b]"),
1667            "multi-bench findings should be prefixed: {}",
1668            sensor_report.findings[1].message
1669        );
1670        let data_0 = sensor_report.findings[0].data.as_ref().unwrap();
1671        assert_eq!(data_0["bench_name"], "bench-a");
1672        let data_1 = sensor_report.findings[1].data.as_ref().unwrap();
1673        assert_eq!(data_1["bench_name"], "bench-b");
1674    }
1675
1676    #[test]
1677    fn test_build_aggregated_multi_bench_fingerprints_unique() {
1678        let report_a = make_fail_report();
1679        let report_b = make_fail_report();
1680        let outcome_a = make_bench_outcome("bench-a", report_a, true, true, "extras/bench-a");
1681        let outcome_b = make_bench_outcome("bench-b", report_b, true, true, "extras/bench-b");
1682
1683        let builder =
1684            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1685
1686        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1687
1688        let fp_a = sensor_report.findings[0].fingerprint.as_ref().unwrap();
1689        let fp_b = sensor_report.findings[1].fingerprint.as_ref().unwrap();
1690        assert_ne!(fp_a, fp_b, "fingerprints should differ per bench");
1691        assert_eq!(fp_a.len(), 64, "fingerprint should be 64-char hex");
1692        assert_eq!(fp_b.len(), 64, "fingerprint should be 64-char hex");
1693    }
1694
1695    #[test]
1696    fn test_build_aggregated_multi_bench_verdict_worst_wins() {
1697        let report_pass = make_pass_report();
1698        let report_fail = make_fail_report();
1699        let outcome_pass =
1700            make_bench_outcome("bench-a", report_pass, false, false, "extras/bench-a");
1701        let outcome_fail = make_bench_outcome("bench-b", report_fail, true, true, "extras/bench-b");
1702
1703        let builder =
1704            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1705
1706        let (sensor_report, _md) = builder.build_aggregated(&[outcome_pass, outcome_fail]);
1707
1708        assert_eq!(
1709            sensor_report.verdict.status,
1710            SensorVerdictStatus::Fail,
1711            "worst verdict should win"
1712        );
1713    }
1714
1715    #[test]
1716    fn test_build_aggregated_multi_bench_counts_summed() {
1717        let report_a = make_fail_report();
1718        let report_b = make_warn_report();
1719        let outcome_a = make_bench_outcome("bench-a", report_a, true, true, "extras/bench-a");
1720        let outcome_b = make_bench_outcome("bench-b", report_b, true, true, "extras/bench-b");
1721
1722        let builder =
1723            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1724
1725        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1726
1727        assert_eq!(sensor_report.verdict.counts.info, 2, "pass counts summed");
1728        assert_eq!(sensor_report.verdict.counts.warn, 1, "warn counts summed");
1729        assert_eq!(sensor_report.verdict.counts.error, 1, "fail counts summed");
1730    }
1731
1732    #[test]
1733    fn test_build_aggregated_multi_bench_reasons_deduped() {
1734        let report_a = make_pass_report();
1735        let report_b = make_pass_report();
1736        let outcome_a = make_bench_outcome("bench-a", report_a, false, false, "extras/bench-a");
1737        let outcome_b = make_bench_outcome("bench-b", report_b, false, false, "extras/bench-b");
1738
1739        let builder =
1740            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1741
1742        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1743
1744        let no_baseline_count = sensor_report
1745            .verdict
1746            .reasons
1747            .iter()
1748            .filter(|r| r.as_str() == BASELINE_REASON_NO_BASELINE)
1749            .count();
1750        assert_eq!(
1751            no_baseline_count, 1,
1752            "no_baseline should appear exactly once"
1753        );
1754    }
1755
1756    #[test]
1757    fn test_build_aggregated_multi_bench_markdown_joined() {
1758        let report_a = make_pass_report();
1759        let report_b = make_pass_report();
1760        let outcome_a = make_bench_outcome("bench-a", report_a, false, false, "extras/bench-a");
1761        let outcome_b = make_bench_outcome("bench-b", report_b, false, false, "extras/bench-b");
1762
1763        let builder =
1764            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1765
1766        let (_sensor_report, md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1767
1768        assert!(md.contains("bench-a"), "markdown should contain bench-a");
1769        assert!(md.contains("bench-b"), "markdown should contain bench-b");
1770        assert!(
1771            md.contains("\n---\n\n"),
1772            "multi-bench markdown should have --- separator"
1773        );
1774    }
1775
1776    #[test]
1777    fn test_build_aggregated_multi_bench_truncation() {
1778        use perfgate_types::FindingData;
1779
1780        let findings: Vec<ReportFinding> = (0..10)
1781            .map(|i| ReportFinding {
1782                check_id: "perf.budget".to_string(),
1783                code: FINDING_CODE_METRIC_FAIL.to_string(),
1784                severity: Severity::Fail,
1785                message: format!("metric {} regression", i),
1786                data: Some(FindingData {
1787                    metric_name: format!("metric_{}", i),
1788                    baseline: 100.0,
1789                    current: 150.0,
1790                    regression_pct: 50.0,
1791                    threshold: 0.2,
1792                    direction: perfgate_types::Direction::Lower,
1793                }),
1794            })
1795            .collect();
1796
1797        let report = PerfgateReport {
1798            report_type: REPORT_SCHEMA_V1.to_string(),
1799            verdict: Verdict {
1800                status: VerdictStatus::Fail,
1801                counts: VerdictCounts {
1802                    pass: 0,
1803                    warn: 0,
1804                    fail: 10,
1805                },
1806                reasons: vec![],
1807            },
1808            compare: None,
1809            findings,
1810            summary: ReportSummary {
1811                pass_count: 0,
1812                warn_count: 0,
1813                fail_count: 10,
1814                total_count: 10,
1815            },
1816        };
1817
1818        let outcome = make_bench_outcome("bench-a", report, true, true, "extras");
1819
1820        let builder =
1821            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
1822                .max_findings(5);
1823
1824        let (sensor_report, _md) = builder.build_aggregated(&[outcome]);
1825
1826        assert_eq!(sensor_report.findings.len(), 5);
1827        assert_eq!(sensor_report.findings[4].check_id, CHECK_ID_TOOL_TRUNCATION);
1828        assert_eq!(sensor_report.data["findings_total"], 10);
1829        assert_eq!(sensor_report.data["findings_emitted"], 4);
1830        assert!(
1831            sensor_report
1832                .verdict
1833                .reasons
1834                .contains(&"truncated".to_string())
1835        );
1836    }
1837
1838    #[test]
1839    fn test_build_aggregated_multi_bench_artifacts_sorted() {
1840        let report_a = make_pass_report();
1841        let report_b = make_pass_report();
1842        let outcome_a = make_bench_outcome("bench-a", report_a, true, true, "extras/bench-a");
1843        let outcome_b = make_bench_outcome("bench-b", report_b, true, true, "extras/bench-b");
1844
1845        let builder =
1846            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1847
1848        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1849
1850        let arts = &sensor_report.artifacts;
1851        for window in arts.windows(2) {
1852            assert!(
1853                (&window[0].artifact_type, &window[0].path)
1854                    <= (&window[1].artifact_type, &window[1].path),
1855                "artifacts not sorted: {:?} > {:?}",
1856                (&window[0].artifact_type, &window[0].path),
1857                (&window[1].artifact_type, &window[1].path)
1858            );
1859        }
1860    }
1861
1862    #[test]
1863    fn test_build_aggregated_baseline_all_available() {
1864        let report_a = make_pass_report();
1865        let report_b = make_pass_report();
1866        let outcome_a = make_bench_outcome("bench-a", report_a, true, true, "extras/bench-a");
1867        let outcome_b = make_bench_outcome("bench-b", report_b, true, true, "extras/bench-b");
1868
1869        let builder =
1870            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1871
1872        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1873
1874        assert_eq!(
1875            sensor_report.run.capabilities.baseline.status,
1876            CapabilityStatus::Available,
1877            "all baselines available → status = available"
1878        );
1879        assert!(
1880            sensor_report.run.capabilities.baseline.reason.is_none(),
1881            "all baselines available → no reason"
1882        );
1883    }
1884
1885    #[test]
1886    fn test_build_aggregated_baseline_partial() {
1887        let report_a = make_pass_report();
1888        let report_b = make_pass_report();
1889        let outcome_a = make_bench_outcome("bench-a", report_a, true, true, "extras/bench-a");
1890        let outcome_b = make_bench_outcome("bench-b", report_b, false, false, "extras/bench-b");
1891
1892        let builder =
1893            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1894
1895        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1896
1897        assert_eq!(
1898            sensor_report.run.capabilities.baseline.status,
1899            CapabilityStatus::Unavailable,
1900            "partial baselines → status = unavailable"
1901        );
1902        assert!(
1903            sensor_report.run.capabilities.baseline.reason.is_none(),
1904            "partial baselines → reason = null (some have baselines)"
1905        );
1906    }
1907
1908    #[test]
1909    fn test_build_aggregated_baseline_none() {
1910        let report_a = make_pass_report();
1911        let report_b = make_pass_report();
1912        let outcome_a = make_bench_outcome("bench-a", report_a, false, false, "extras/bench-a");
1913        let outcome_b = make_bench_outcome("bench-b", report_b, false, false, "extras/bench-b");
1914
1915        let builder =
1916            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1917
1918        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1919
1920        assert_eq!(
1921            sensor_report.run.capabilities.baseline.status,
1922            CapabilityStatus::Unavailable,
1923            "no baselines → status = unavailable"
1924        );
1925        assert_eq!(
1926            sensor_report.run.capabilities.baseline.reason,
1927            Some(BASELINE_REASON_NO_BASELINE.to_string()),
1928            "no baselines → reason = no_baseline"
1929        );
1930    }
1931
1932    // --- BenchOutcome::Error aggregation tests ---
1933
1934    #[test]
1935    fn test_build_aggregated_mixed_success_and_error() {
1936        let report_a = make_warn_report();
1937        let outcome_a = make_bench_outcome("bench-a", report_a, false, false, "extras/bench-a");
1938        let outcome_b = make_error_outcome(
1939            "bench-b",
1940            "failed to spawn: no such file",
1941            perfgate_types::STAGE_RUN_COMMAND,
1942            perfgate_types::ERROR_KIND_EXEC,
1943        );
1944
1945        let builder =
1946            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1947
1948        let (sensor_report, md) = builder.build_aggregated(&[outcome_a, outcome_b]);
1949
1950        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Fail);
1951
1952        assert_eq!(sensor_report.verdict.counts.info, 1);
1953        assert_eq!(sensor_report.verdict.counts.warn, 1);
1954        assert_eq!(sensor_report.verdict.counts.error, 1);
1955
1956        assert!(
1957            sensor_report
1958                .verdict
1959                .reasons
1960                .contains(&VERDICT_REASON_TOOL_ERROR.to_string()),
1961            "should have tool_error reason"
1962        );
1963        assert!(
1964            sensor_report
1965                .verdict
1966                .reasons
1967                .contains(&BASELINE_REASON_NO_BASELINE.to_string()),
1968            "should have no_baseline reason"
1969        );
1970
1971        assert_eq!(sensor_report.findings.len(), 2);
1972        assert!(sensor_report.findings[0].message.starts_with("[bench-a]"));
1973        assert!(sensor_report.findings[1].message.starts_with("[bench-b]"));
1974        assert_eq!(sensor_report.findings[1].check_id, CHECK_ID_TOOL_RUNTIME);
1975
1976        assert_eq!(sensor_report.data["summary"]["bench_count"], 2);
1977
1978        assert!(md.contains("bench-b"));
1979        assert!(md.contains("**Error:**"));
1980    }
1981
1982    #[test]
1983    fn test_build_aggregated_single_error_outcome() {
1984        let outcome = make_error_outcome(
1985            "bench-a",
1986            "config parse failure",
1987            perfgate_types::STAGE_CONFIG_PARSE,
1988            perfgate_types::ERROR_KIND_PARSE,
1989        );
1990
1991        let builder =
1992            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
1993
1994        let (sensor_report, _md) = builder.build_aggregated(&[outcome]);
1995
1996        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Fail);
1997        assert_eq!(sensor_report.verdict.counts.error, 1);
1998        assert_eq!(sensor_report.findings.len(), 1);
1999        assert_eq!(sensor_report.findings[0].check_id, CHECK_ID_TOOL_RUNTIME);
2000        assert_eq!(sensor_report.data["summary"]["bench_count"], 1);
2001    }
2002
2003    #[test]
2004    fn test_build_aggregated_error_no_artifacts() {
2005        let outcome_a =
2006            make_bench_outcome("bench-a", make_pass_report(), true, true, "extras/bench-a");
2007        let outcome_b = make_error_outcome(
2008            "bench-b",
2009            "spawn error",
2010            perfgate_types::STAGE_RUN_COMMAND,
2011            perfgate_types::ERROR_KIND_EXEC,
2012        );
2013
2014        let builder =
2015            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
2016
2017        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
2018
2019        let has_bench_a_artifact = sensor_report
2020            .artifacts
2021            .iter()
2022            .any(|a| a.path.contains("bench-a"));
2023        let has_bench_b_artifact = sensor_report
2024            .artifacts
2025            .iter()
2026            .any(|a| a.path.contains("bench-b"));
2027
2028        assert!(has_bench_a_artifact, "bench-a should have artifacts");
2029        assert!(!has_bench_b_artifact, "bench-b should NOT have artifacts");
2030    }
2031
2032    #[test]
2033    fn test_build_aggregated_error_finding_data() {
2034        let outcome = make_error_outcome(
2035            "bench-x",
2036            "spawn error",
2037            perfgate_types::STAGE_RUN_COMMAND,
2038            perfgate_types::ERROR_KIND_EXEC,
2039        );
2040
2041        let builder =
2042            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
2043
2044        let (sensor_report, _md) = builder.build_aggregated(&[outcome]);
2045
2046        let finding = &sensor_report.findings[0];
2047        let data = finding.data.as_ref().expect("finding should have data");
2048        assert_eq!(data["stage"], perfgate_types::STAGE_RUN_COMMAND);
2049        assert_eq!(data["error_kind"], perfgate_types::ERROR_KIND_EXEC);
2050        assert!(
2051            data.get("bench_name").is_none() || data["bench_name"].is_null(),
2052            "single bench error should not have bench_name in data"
2053        );
2054    }
2055
2056    #[test]
2057    fn test_build_aggregated_error_finding_data_multi_bench() {
2058        let outcome_a = make_bench_outcome(
2059            "bench-a",
2060            make_pass_report(),
2061            false,
2062            false,
2063            "extras/bench-a",
2064        );
2065        let outcome_b = make_error_outcome(
2066            "bench-b",
2067            "spawn error",
2068            perfgate_types::STAGE_RUN_COMMAND,
2069            perfgate_types::ERROR_KIND_EXEC,
2070        );
2071
2072        let builder =
2073            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
2074
2075        let (sensor_report, _md) = builder.build_aggregated(&[outcome_a, outcome_b]);
2076
2077        let error_finding = sensor_report
2078            .findings
2079            .iter()
2080            .find(|f| f.check_id == CHECK_ID_TOOL_RUNTIME)
2081            .expect("should have error finding");
2082
2083        let data = error_finding
2084            .data
2085            .as_ref()
2086            .expect("finding should have data");
2087        assert_eq!(data["stage"], perfgate_types::STAGE_RUN_COMMAND);
2088        assert_eq!(data["error_kind"], perfgate_types::ERROR_KIND_EXEC);
2089        assert_eq!(data["bench_name"], "bench-b");
2090    }
2091
2092    #[test]
2093    fn test_bench_outcome_bench_name() {
2094        let success = make_bench_outcome("my-bench", make_pass_report(), false, false, "extras");
2095        assert_eq!(success.bench_name(), "my-bench");
2096
2097        let error = make_error_outcome(
2098            "bad-bench",
2099            "error",
2100            perfgate_types::STAGE_RUN_COMMAND,
2101            perfgate_types::ERROR_KIND_EXEC,
2102        );
2103        assert_eq!(error.bench_name(), "bad-bench");
2104    }
2105}
2106
2107#[cfg(test)]
2108mod property_tests {
2109    use super::*;
2110    use proptest::prelude::*;
2111
2112    proptest! {
2113        #[test]
2114        fn fingerprint_deterministic(parts in proptest::collection::vec("[a-zA-Z0-9_\\-]{0,20}", 0..10)) {
2115            let parts_ref: Vec<&str> = parts.iter().map(|s| s.as_str()).collect();
2116            let fp1 = sensor_fingerprint(&parts_ref);
2117            let fp2 = sensor_fingerprint(&parts_ref);
2118            prop_assert_eq!(&fp1, &fp2, "fingerprint should be deterministic");
2119            prop_assert_eq!(fp1.len(), 64, "fingerprint should be 64-char hex");
2120        }
2121
2122        #[test]
2123        fn fingerprint_trailing_empty_trimmed(
2124            prefix in "[a-zA-Z0-9_\\-]{1,10}",
2125            empty_count in 0usize..5
2126        ) {
2127            let mut parts: Vec<String> = vec![prefix.clone()];
2128            for _ in 0..empty_count {
2129                parts.push("".to_string());
2130            }
2131            let parts_ref: Vec<&str> = parts.iter().map(|s| s.as_str()).collect();
2132            let fp_with_empty = sensor_fingerprint(&parts_ref);
2133
2134            let parts_no_empty: Vec<&str> = vec![prefix.as_str()];
2135            let fp_no_empty = sensor_fingerprint(&parts_no_empty);
2136
2137            prop_assert_eq!(fp_with_empty, fp_no_empty, "trailing empty parts should be trimmed");
2138        }
2139
2140        #[test]
2141        fn fingerprint_different_inputs_different_output(
2142            a in "[a-zA-Z0-9_\\-]{1,10}",
2143            b in "[a-zA-Z0-9_\\-]{1,10}",
2144            c in "[a-zA-Z0-9_\\-]{1,10}"
2145        ) {
2146            prop_assume!(a != b || b != c);
2147            let fp1 = sensor_fingerprint(&[&a, &b]);
2148            let fp2 = sensor_fingerprint(&[&b, &c]);
2149            prop_assert_ne!(fp1, fp2, "different inputs should produce different fingerprints");
2150        }
2151
2152        #[test]
2153        fn fingerprint_order_matters(
2154            a in "[a-zA-Z0-9_\\-]{1,10}",
2155            b in "[a-zA-Z0-9_\\-]{1,10}"
2156        ) {
2157            prop_assume!(a != b);
2158            let fp1 = sensor_fingerprint(&[&a, &b]);
2159            let fp2 = sensor_fingerprint(&[&b, &a]);
2160            prop_assert_ne!(fp1, fp2, "fingerprint order should matter");
2161        }
2162    }
2163}
2164
2165#[cfg(test)]
2166mod snapshot_tests {
2167    use super::*;
2168    use insta::assert_json_snapshot;
2169    use perfgate_types::{
2170        FINDING_CODE_METRIC_FAIL, FINDING_CODE_METRIC_WARN, REPORT_SCHEMA_V1, ReportFinding,
2171        ReportSummary, Verdict, VerdictCounts,
2172    };
2173
2174    fn make_tool_info() -> ToolInfo {
2175        ToolInfo {
2176            name: "perfgate".to_string(),
2177            version: "0.1.0".to_string(),
2178        }
2179    }
2180
2181    /// Fixed engine capability for platform-independent snapshots.
2182    fn fixed_engine() -> Capability {
2183        Capability {
2184            status: CapabilityStatus::Available,
2185            reason: None,
2186        }
2187    }
2188
2189    #[test]
2190    fn snapshot_pass_report() {
2191        let report = PerfgateReport {
2192            report_type: REPORT_SCHEMA_V1.to_string(),
2193            verdict: Verdict {
2194                status: VerdictStatus::Pass,
2195                counts: VerdictCounts {
2196                    pass: 3,
2197                    warn: 0,
2198                    fail: 0,
2199                },
2200                reasons: vec![],
2201            },
2202            compare: None,
2203            findings: vec![],
2204            summary: ReportSummary {
2205                pass_count: 3,
2206                warn_count: 0,
2207                fail_count: 0,
2208                total_count: 3,
2209            },
2210        };
2211
2212        let sensor_report =
2213            SensorReportBuilder::new(make_tool_info(), "2024-01-15T10:30:00Z".to_string())
2214                .ended_at("2024-01-15T10:31:00Z".to_string(), 60000)
2215                .baseline(true, None)
2216                .engine(fixed_engine())
2217                .build(&report);
2218
2219        assert_json_snapshot!(sensor_report);
2220    }
2221
2222    #[test]
2223    fn snapshot_fail_report() {
2224        let report = PerfgateReport {
2225            report_type: REPORT_SCHEMA_V1.to_string(),
2226            verdict: Verdict {
2227                status: VerdictStatus::Fail,
2228                counts: VerdictCounts {
2229                    pass: 1,
2230                    warn: 0,
2231                    fail: 2,
2232                },
2233                reasons: vec!["wall_ms_fail".to_string(), "max_rss_kb_fail".to_string()],
2234            },
2235            compare: None,
2236            findings: vec![ReportFinding {
2237                check_id: "perf.budget".to_string(),
2238                code: FINDING_CODE_METRIC_FAIL.to_string(),
2239                severity: Severity::Fail,
2240                message: "wall_ms regression: +30.00% (threshold: 20.0%)".to_string(),
2241                data: None,
2242            }],
2243            summary: ReportSummary {
2244                pass_count: 1,
2245                warn_count: 0,
2246                fail_count: 2,
2247                total_count: 3,
2248            },
2249        };
2250
2251        let sensor_report =
2252            SensorReportBuilder::new(make_tool_info(), "2024-01-15T10:30:00Z".to_string())
2253                .ended_at("2024-01-15T10:31:00Z".to_string(), 60000)
2254                .baseline(false, Some("no_baseline".to_string()))
2255                .engine(fixed_engine())
2256                .build(&report);
2257
2258        assert_json_snapshot!(sensor_report);
2259    }
2260
2261    #[test]
2262    fn snapshot_error_report() {
2263        let sensor_report =
2264            SensorReportBuilder::new(make_tool_info(), "2024-01-15T10:30:00Z".to_string())
2265                .ended_at("2024-01-15T10:30:01Z".to_string(), 1000)
2266                .baseline(false, None)
2267                .engine(fixed_engine())
2268                .build_error(
2269                    "failed to parse config: invalid TOML at line 5",
2270                    perfgate_types::STAGE_CONFIG_PARSE,
2271                    perfgate_types::ERROR_KIND_PARSE,
2272                );
2273
2274        assert_json_snapshot!(sensor_report);
2275    }
2276
2277    #[test]
2278    fn snapshot_aggregated_multi_bench() {
2279        let report_a = PerfgateReport {
2280            report_type: REPORT_SCHEMA_V1.to_string(),
2281            verdict: Verdict {
2282                status: VerdictStatus::Warn,
2283                counts: VerdictCounts {
2284                    pass: 1,
2285                    warn: 1,
2286                    fail: 0,
2287                },
2288                reasons: vec!["wall_ms_warn".to_string()],
2289            },
2290            compare: None,
2291            findings: vec![ReportFinding {
2292                check_id: "perf.budget".to_string(),
2293                code: FINDING_CODE_METRIC_WARN.to_string(),
2294                severity: Severity::Warn,
2295                message: "wall_ms regression: +15.00%".to_string(),
2296                data: None,
2297            }],
2298            summary: ReportSummary {
2299                pass_count: 1,
2300                warn_count: 1,
2301                fail_count: 0,
2302                total_count: 2,
2303            },
2304        };
2305
2306        let report_b = PerfgateReport {
2307            report_type: REPORT_SCHEMA_V1.to_string(),
2308            verdict: Verdict {
2309                status: VerdictStatus::Pass,
2310                counts: VerdictCounts {
2311                    pass: 2,
2312                    warn: 0,
2313                    fail: 0,
2314                },
2315                reasons: vec![],
2316            },
2317            compare: None,
2318            findings: vec![],
2319            summary: ReportSummary {
2320                pass_count: 2,
2321                warn_count: 0,
2322                fail_count: 0,
2323                total_count: 2,
2324            },
2325        };
2326
2327        let outcome_a = BenchOutcome::Success {
2328            bench_name: "bench-a".to_string(),
2329            report: report_a,
2330            has_compare: true,
2331            baseline_available: true,
2332            markdown: "## bench-a\n\nResults: +15%".to_string(),
2333            extras_prefix: "extras/bench-a".to_string(),
2334        };
2335
2336        let outcome_b = BenchOutcome::Success {
2337            bench_name: "bench-b".to_string(),
2338            report: report_b,
2339            has_compare: true,
2340            baseline_available: true,
2341            markdown: "## bench-b\n\nResults: pass".to_string(),
2342            extras_prefix: "extras/bench-b".to_string(),
2343        };
2344
2345        let (sensor_report, _md) =
2346            SensorReportBuilder::new(make_tool_info(), "2024-01-15T10:30:00Z".to_string())
2347                .ended_at("2024-01-15T10:32:00Z".to_string(), 120000)
2348                .engine(fixed_engine())
2349                .build_aggregated(&[outcome_a, outcome_b]);
2350
2351        assert_json_snapshot!(sensor_report);
2352    }
2353
2354    #[test]
2355    fn snapshot_truncated_report() {
2356        use perfgate_types::FindingData;
2357
2358        let findings: Vec<ReportFinding> = (0..5)
2359            .map(|i| ReportFinding {
2360                check_id: "perf.budget".to_string(),
2361                code: FINDING_CODE_METRIC_FAIL.to_string(),
2362                severity: Severity::Fail,
2363                message: format!("metric_{} regression", i),
2364                data: Some(FindingData {
2365                    metric_name: format!("metric_{}", i),
2366                    baseline: 100.0,
2367                    current: 150.0,
2368                    regression_pct: 50.0,
2369                    threshold: 0.2,
2370                    direction: perfgate_types::Direction::Lower,
2371                }),
2372            })
2373            .collect();
2374
2375        let report = PerfgateReport {
2376            report_type: REPORT_SCHEMA_V1.to_string(),
2377            verdict: Verdict {
2378                status: VerdictStatus::Fail,
2379                counts: VerdictCounts {
2380                    pass: 0,
2381                    warn: 0,
2382                    fail: 5,
2383                },
2384                reasons: vec![],
2385            },
2386            compare: None,
2387            findings,
2388            summary: ReportSummary {
2389                pass_count: 0,
2390                warn_count: 0,
2391                fail_count: 5,
2392                total_count: 5,
2393            },
2394        };
2395
2396        let sensor_report =
2397            SensorReportBuilder::new(make_tool_info(), "2024-01-15T10:30:00Z".to_string())
2398                .ended_at("2024-01-15T10:31:00Z".to_string(), 60000)
2399                .baseline(true, None)
2400                .engine(fixed_engine())
2401                .max_findings(3)
2402                .build(&report);
2403
2404        assert_json_snapshot!(sensor_report);
2405    }
2406}
2407
2408#[cfg(test)]
2409mod schema_conformance_tests {
2410    use super::*;
2411    use perfgate_types::{
2412        FINDING_CODE_METRIC_FAIL, REPORT_SCHEMA_V1, ReportFinding, ReportSummary, Verdict,
2413        VerdictCounts,
2414    };
2415
2416    fn make_tool_info() -> ToolInfo {
2417        ToolInfo {
2418            name: "perfgate".to_string(),
2419            version: "0.1.0".to_string(),
2420        }
2421    }
2422
2423    fn make_pass_report() -> PerfgateReport {
2424        PerfgateReport {
2425            report_type: REPORT_SCHEMA_V1.to_string(),
2426            verdict: Verdict {
2427                status: VerdictStatus::Pass,
2428                counts: VerdictCounts {
2429                    pass: 2,
2430                    warn: 0,
2431                    fail: 0,
2432                },
2433                reasons: vec![],
2434            },
2435            compare: None,
2436            findings: vec![],
2437            summary: ReportSummary {
2438                pass_count: 2,
2439                warn_count: 0,
2440                fail_count: 0,
2441                total_count: 2,
2442            },
2443        }
2444    }
2445
2446    fn make_fail_report_with_finding() -> PerfgateReport {
2447        PerfgateReport {
2448            report_type: REPORT_SCHEMA_V1.to_string(),
2449            verdict: Verdict {
2450                status: VerdictStatus::Fail,
2451                counts: VerdictCounts {
2452                    pass: 0,
2453                    warn: 0,
2454                    fail: 1,
2455                },
2456                reasons: vec!["wall_ms_fail".to_string()],
2457            },
2458            compare: None,
2459            findings: vec![ReportFinding {
2460                check_id: "perf.budget".to_string(),
2461                code: FINDING_CODE_METRIC_FAIL.to_string(),
2462                severity: Severity::Fail,
2463                message: "wall_ms regression: +25.00%".to_string(),
2464                data: None,
2465            }],
2466            summary: ReportSummary {
2467                pass_count: 0,
2468                warn_count: 0,
2469                fail_count: 1,
2470                total_count: 1,
2471            },
2472        }
2473    }
2474
2475    /// Validate that a serialized SensorReport has the required top-level fields
2476    /// matching the vendored sensor.report.v1 schema.
2477    fn assert_schema_conformance(json: &serde_json::Value) {
2478        let obj = json.as_object().expect("report should be a JSON object");
2479
2480        // Required fields per schema: schema, tool, run, verdict, findings, data
2481        assert!(obj.contains_key("schema"), "missing 'schema' field");
2482        assert!(obj.contains_key("tool"), "missing 'tool' field");
2483        assert!(obj.contains_key("run"), "missing 'run' field");
2484        assert!(obj.contains_key("verdict"), "missing 'verdict' field");
2485        assert!(obj.contains_key("findings"), "missing 'findings' field");
2486        assert!(obj.contains_key("data"), "missing 'data' field");
2487
2488        // schema must be the const value
2489        assert_eq!(
2490            obj["schema"].as_str().unwrap(),
2491            "sensor.report.v1",
2492            "schema field must be 'sensor.report.v1'"
2493        );
2494
2495        // tool must have name and version
2496        let tool = obj["tool"].as_object().expect("tool should be object");
2497        assert!(tool.contains_key("name"), "tool missing 'name'");
2498        assert!(tool.contains_key("version"), "tool missing 'version'");
2499        assert!(tool["name"].is_string());
2500        assert!(tool["version"].is_string());
2501
2502        // run must have started_at and capabilities
2503        let run = obj["run"].as_object().expect("run should be object");
2504        assert!(run.contains_key("started_at"), "run missing 'started_at'");
2505        assert!(
2506            run.contains_key("capabilities"),
2507            "run missing 'capabilities'"
2508        );
2509        assert!(run["started_at"].is_string());
2510        assert!(run["capabilities"].is_object());
2511
2512        // verdict must have status, counts, reasons
2513        let verdict = obj["verdict"]
2514            .as_object()
2515            .expect("verdict should be object");
2516        assert!(verdict.contains_key("status"), "verdict missing 'status'");
2517        assert!(verdict.contains_key("counts"), "verdict missing 'counts'");
2518        assert!(verdict.contains_key("reasons"), "verdict missing 'reasons'");
2519
2520        let valid_statuses = ["pass", "warn", "fail", "skip"];
2521        let status = verdict["status"].as_str().unwrap();
2522        assert!(
2523            valid_statuses.contains(&status),
2524            "verdict.status '{}' not in {:?}",
2525            status,
2526            valid_statuses
2527        );
2528
2529        let counts = verdict["counts"]
2530            .as_object()
2531            .expect("counts should be object");
2532        assert!(counts.contains_key("info"), "counts missing 'info'");
2533        assert!(counts.contains_key("warn"), "counts missing 'warn'");
2534        assert!(counts.contains_key("error"), "counts missing 'error'");
2535        assert!(counts["info"].as_u64().is_some());
2536        assert!(counts["warn"].as_u64().is_some());
2537        assert!(counts["error"].as_u64().is_some());
2538
2539        assert!(verdict["reasons"].is_array());
2540
2541        // findings must be an array
2542        assert!(obj["findings"].is_array());
2543
2544        // data must be an object
2545        assert!(obj["data"].is_object());
2546    }
2547
2548    /// Validate individual findings conform to the schema.
2549    fn assert_finding_conformance(finding: &serde_json::Value) {
2550        let obj = finding
2551            .as_object()
2552            .expect("finding should be a JSON object");
2553
2554        // Required: check_id, code, severity, message
2555        assert!(obj.contains_key("check_id"), "finding missing 'check_id'");
2556        assert!(obj.contains_key("code"), "finding missing 'code'");
2557        assert!(obj.contains_key("severity"), "finding missing 'severity'");
2558        assert!(obj.contains_key("message"), "finding missing 'message'");
2559
2560        assert!(obj["check_id"].is_string());
2561        assert!(obj["code"].is_string());
2562        assert!(obj["message"].is_string());
2563
2564        let valid_severities = ["info", "warn", "error"];
2565        let severity = obj["severity"].as_str().unwrap();
2566        assert!(
2567            valid_severities.contains(&severity),
2568            "finding.severity '{}' not in {:?}",
2569            severity,
2570            valid_severities
2571        );
2572
2573        // fingerprint, if present, must be 64-char hex
2574        if let Some(fp) = obj.get("fingerprint") {
2575            let fp_str = fp.as_str().expect("fingerprint should be string");
2576            assert_eq!(fp_str.len(), 64, "fingerprint should be 64-char hex");
2577            assert!(
2578                fp_str.chars().all(|c| c.is_ascii_hexdigit()),
2579                "fingerprint should contain only hex chars"
2580            );
2581        }
2582
2583        // No additional properties beyond what the schema allows
2584        let allowed_keys = [
2585            "check_id",
2586            "code",
2587            "severity",
2588            "message",
2589            "fingerprint",
2590            "data",
2591        ];
2592        for key in obj.keys() {
2593            assert!(
2594                allowed_keys.contains(&key.as_str()),
2595                "unexpected finding field: '{}'",
2596                key
2597            );
2598        }
2599    }
2600
2601    #[test]
2602    fn pass_report_conforms_to_vendored_schema() {
2603        let report = make_pass_report();
2604        let sensor = SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
2605            .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
2606            .baseline(true, None)
2607            .build(&report);
2608
2609        let json: serde_json::Value = serde_json::to_value(&sensor).unwrap();
2610        assert_schema_conformance(&json);
2611        assert!(json["findings"].as_array().unwrap().is_empty());
2612    }
2613
2614    #[test]
2615    fn fail_report_conforms_to_vendored_schema() {
2616        let report = make_fail_report_with_finding();
2617        let sensor = SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
2618            .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
2619            .baseline(true, None)
2620            .build(&report);
2621
2622        let json: serde_json::Value = serde_json::to_value(&sensor).unwrap();
2623        assert_schema_conformance(&json);
2624
2625        let findings = json["findings"].as_array().unwrap();
2626        assert!(!findings.is_empty());
2627        for finding in findings {
2628            assert_finding_conformance(finding);
2629        }
2630    }
2631
2632    #[test]
2633    fn error_report_conforms_to_vendored_schema() {
2634        let sensor = SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
2635            .ended_at("2024-01-01T00:00:01Z".to_string(), 1000)
2636            .baseline(false, None)
2637            .build_error(
2638                "config parse failed",
2639                perfgate_types::STAGE_CONFIG_PARSE,
2640                perfgate_types::ERROR_KIND_PARSE,
2641            );
2642
2643        let json: serde_json::Value = serde_json::to_value(&sensor).unwrap();
2644        assert_schema_conformance(&json);
2645
2646        let findings = json["findings"].as_array().unwrap();
2647        assert_eq!(findings.len(), 1);
2648        assert_finding_conformance(&findings[0]);
2649        assert_eq!(findings[0]["severity"], "error");
2650    }
2651
2652    #[test]
2653    fn report_with_artifacts_conforms_to_vendored_schema() {
2654        let report = make_pass_report();
2655        let sensor = SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
2656            .artifact("report.json".to_string(), "sensor_report".to_string())
2657            .artifact("comment.md".to_string(), "markdown".to_string())
2658            .baseline(true, None)
2659            .build(&report);
2660
2661        let json: serde_json::Value = serde_json::to_value(&sensor).unwrap();
2662        assert_schema_conformance(&json);
2663
2664        // artifacts, if present, must have path and type
2665        let artifacts = json["artifacts"].as_array().unwrap();
2666        assert_eq!(artifacts.len(), 2);
2667        for artifact in artifacts {
2668            let obj = artifact.as_object().unwrap();
2669            assert!(obj.contains_key("path"), "artifact missing 'path'");
2670            assert!(obj.contains_key("type"), "artifact missing 'type'");
2671            assert!(obj["path"].is_string());
2672            assert!(obj["type"].is_string());
2673        }
2674    }
2675
2676    #[test]
2677    fn report_without_ended_at_conforms_to_vendored_schema() {
2678        let report = make_pass_report();
2679        // No ended_at or duration_ms — these are optional in the schema
2680        let sensor = SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
2681            .baseline(true, None)
2682            .build(&report);
2683
2684        let json: serde_json::Value = serde_json::to_value(&sensor).unwrap();
2685        assert_schema_conformance(&json);
2686
2687        let run = json["run"].as_object().unwrap();
2688        assert!(!run.contains_key("ended_at") || run["ended_at"].is_string());
2689        assert!(!run.contains_key("duration_ms") || run["duration_ms"].is_u64());
2690    }
2691
2692    #[test]
2693    fn truncated_report_findings_all_conform() {
2694        use perfgate_types::FindingData;
2695
2696        let findings: Vec<ReportFinding> = (0..10)
2697            .map(|i| ReportFinding {
2698                check_id: "perf.budget".to_string(),
2699                code: FINDING_CODE_METRIC_FAIL.to_string(),
2700                severity: Severity::Fail,
2701                message: format!("metric_{} regression", i),
2702                data: Some(FindingData {
2703                    metric_name: format!("metric_{}", i),
2704                    baseline: 100.0,
2705                    current: 150.0,
2706                    regression_pct: 50.0,
2707                    threshold: 0.2,
2708                    direction: perfgate_types::Direction::Lower,
2709                }),
2710            })
2711            .collect();
2712
2713        let report = PerfgateReport {
2714            report_type: REPORT_SCHEMA_V1.to_string(),
2715            verdict: Verdict {
2716                status: VerdictStatus::Fail,
2717                counts: VerdictCounts {
2718                    pass: 0,
2719                    warn: 0,
2720                    fail: 10,
2721                },
2722                reasons: vec![],
2723            },
2724            compare: None,
2725            findings,
2726            summary: ReportSummary {
2727                pass_count: 0,
2728                warn_count: 0,
2729                fail_count: 10,
2730                total_count: 10,
2731            },
2732        };
2733
2734        let sensor = SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
2735            .baseline(true, None)
2736            .max_findings(5)
2737            .build(&report);
2738
2739        let json: serde_json::Value = serde_json::to_value(&sensor).unwrap();
2740        assert_schema_conformance(&json);
2741
2742        let findings = json["findings"].as_array().unwrap();
2743        for finding in findings {
2744            assert_finding_conformance(finding);
2745        }
2746    }
2747
2748    #[test]
2749    fn serialized_report_no_additional_top_level_properties() {
2750        let report = make_pass_report();
2751        let sensor = SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
2752            .ended_at("2024-01-01T00:01:00Z".to_string(), 60000)
2753            .baseline(true, None)
2754            .build(&report);
2755
2756        let json: serde_json::Value = serde_json::to_value(&sensor).unwrap();
2757        let obj = json.as_object().unwrap();
2758
2759        // Schema says additionalProperties: false
2760        let allowed_top_level = [
2761            "schema",
2762            "tool",
2763            "run",
2764            "verdict",
2765            "findings",
2766            "artifacts",
2767            "data",
2768        ];
2769        for key in obj.keys() {
2770            assert!(
2771                allowed_top_level.contains(&key.as_str()),
2772                "unexpected top-level field: '{}'",
2773                key
2774            );
2775        }
2776    }
2777
2778    #[test]
2779    fn verdict_counts_are_non_negative_integers() {
2780        let report = make_fail_report_with_finding();
2781        let sensor = SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
2782            .baseline(true, None)
2783            .build(&report);
2784
2785        let json: serde_json::Value = serde_json::to_value(&sensor).unwrap();
2786        let counts = &json["verdict"]["counts"];
2787
2788        assert!(counts["info"].as_u64().is_some(), "info should be u64");
2789        assert!(counts["warn"].as_u64().is_some(), "warn should be u64");
2790        assert!(counts["error"].as_u64().is_some(), "error should be u64");
2791    }
2792
2793    #[test]
2794    fn capabilities_baseline_has_valid_status() {
2795        let report = make_pass_report();
2796        let sensor = SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string())
2797            .baseline(false, Some("no_baseline".to_string()))
2798            .build(&report);
2799
2800        let json: serde_json::Value = serde_json::to_value(&sensor).unwrap();
2801        let caps = &json["run"]["capabilities"];
2802        let baseline = caps["baseline"].as_object().unwrap();
2803        let status = baseline["status"].as_str().unwrap();
2804        let valid = ["available", "unavailable", "skipped"];
2805        assert!(
2806            valid.contains(&status),
2807            "capability status '{}' not in {:?}",
2808            status,
2809            valid
2810        );
2811    }
2812}