Skip to main content

perfgate_sensor/
lib.rs

1//! perfgate-sensor: Cockpit mode and sensor report generation.
2//!
3//! This crate provides functionality for generating `sensor.report.v1` JSON
4//! envelopes compatible with external performance monitoring tools like Cockpit.
5
6use perfgate_sha256::sha256_hex;
7pub use perfgate_types::{
8    Capability, CapabilityStatus, PerfgateReport, SENSOR_REPORT_SCHEMA_V1, SensorArtifact,
9    SensorCapabilities, SensorFinding, SensorReport, SensorRunMeta, SensorSeverity, SensorVerdict,
10    SensorVerdictCounts, SensorVerdictStatus, Severity, ToolInfo,
11};
12
13/// Error code for tool-level truncation.
14pub const FINDING_CODE_TRUNCATED: &str = "tool_truncation";
15
16/// Check ID for tool-level truncation findings.
17pub const CHECK_ID_TOOL_TRUNCATION: &str = "perfgate.tool";
18
19/// Builder for creating SensorReports.
20pub struct SensorReportBuilder {
21    tool: ToolInfo,
22    started_at: String,
23    ended_at: Option<String>,
24    duration_ms: Option<u64>,
25    baseline_available: bool,
26    baseline_reason: Option<String>,
27    max_findings: usize,
28    artifacts: Vec<SensorArtifact>,
29}
30
31impl SensorReportBuilder {
32    pub fn new(tool: ToolInfo, started_at: String) -> Self {
33        Self {
34            tool,
35            started_at,
36            ended_at: None,
37            duration_ms: None,
38            baseline_available: false,
39            baseline_reason: None,
40            max_findings: 100,
41            artifacts: Vec::new(),
42        }
43    }
44
45    pub fn ended_at(mut self, ended_at: String, duration_ms: u64) -> Self {
46        self.ended_at = Some(ended_at);
47        self.duration_ms = Some(duration_ms);
48        self
49    }
50
51    pub fn baseline(mut self, available: bool, reason: Option<String>) -> Self {
52        self.baseline_available = available;
53        self.baseline_reason = reason;
54        self
55    }
56
57    pub fn max_findings(mut self, limit: usize) -> Self {
58        self.max_findings = limit;
59        self
60    }
61
62    pub fn artifact(mut self, path: String, artifact_type: String) -> Self {
63        self.artifacts.push(SensorArtifact {
64            path,
65            artifact_type,
66        });
67        self
68    }
69
70    /// Build a single report from a PerfgateReport.
71    pub fn build(self, report: &PerfgateReport) -> SensorReport {
72        let status = match report.verdict.status {
73            perfgate_types::VerdictStatus::Pass => SensorVerdictStatus::Pass,
74            perfgate_types::VerdictStatus::Warn => SensorVerdictStatus::Warn,
75            perfgate_types::VerdictStatus::Fail => SensorVerdictStatus::Fail,
76            perfgate_types::VerdictStatus::Skip => SensorVerdictStatus::Skip,
77        };
78
79        let counts = SensorVerdictCounts {
80            info: report.summary.pass_count,
81            warn: report.summary.warn_count,
82            error: report.summary.fail_count,
83        };
84
85        let mut findings: Vec<SensorFinding> = report
86            .findings
87            .iter()
88            .map(|f| SensorFinding {
89                check_id: f.check_id.clone(),
90                code: f.code.clone(),
91                severity: map_severity(f.severity),
92                message: f.message.clone(),
93                fingerprint: None,
94                data: f.data.as_ref().map(|d| serde_json::to_value(d).unwrap()),
95            })
96            .collect();
97
98        findings.sort_by(|a, b| a.message.cmp(&b.message));
99
100        for f in &mut findings {
101            f.fingerprint = Some(sha256_hex(format!("{}:{}", f.check_id, f.code).as_bytes()));
102        }
103
104        if findings.len() > self.max_findings {
105            truncate_findings(&mut findings, self.max_findings);
106        }
107
108        let run = SensorRunMeta {
109            started_at: self.started_at,
110            ended_at: self.ended_at,
111            duration_ms: self.duration_ms,
112            capabilities: SensorCapabilities {
113                baseline: if self.baseline_available {
114                    Capability {
115                        status: CapabilityStatus::Available,
116                        reason: None,
117                    }
118                } else {
119                    Capability {
120                        status: CapabilityStatus::Unavailable,
121                        reason: self.baseline_reason.clone(),
122                    }
123                },
124                engine: None,
125            },
126        };
127
128        let mut artifacts = self.artifacts;
129        artifacts.sort_by(|a, b| (&a.artifact_type, &a.path).cmp(&(&b.artifact_type, &b.path)));
130
131        SensorReport {
132            schema: SENSOR_REPORT_SCHEMA_V1.to_string(),
133            tool: self.tool,
134            run,
135            verdict: SensorVerdict {
136                status,
137                counts,
138                reasons: report.verdict.reasons.clone(),
139            },
140            findings,
141            artifacts,
142            data: serde_json::json!({
143                "summary": {
144                    "bench_count": 1,
145                    "pass_count": report.summary.pass_count,
146                    "warn_count": report.summary.warn_count,
147                    "fail_count": report.summary.fail_count,
148                    "total_count": report.summary.pass_count + report.summary.warn_count + report.summary.fail_count,
149                }
150            }),
151        }
152    }
153
154    /// Build a report representing a tool error.
155    pub fn build_error(self, message: &str, stage: &str, code: &str) -> SensorReport {
156        let findings = vec![SensorFinding {
157            check_id: perfgate_types::CHECK_ID_TOOL_RUNTIME.to_string(),
158            code: perfgate_types::FINDING_CODE_RUNTIME_ERROR.to_string(),
159            severity: SensorSeverity::Error,
160            message: message.to_string(),
161            fingerprint: Some(sha256_hex(
162                format!(
163                    "{}:{}",
164                    perfgate_types::CHECK_ID_TOOL_RUNTIME,
165                    perfgate_types::FINDING_CODE_RUNTIME_ERROR
166                )
167                .as_bytes(),
168            )),
169            data: Some(serde_json::json!({
170                "stage": stage,
171                "error_kind": code,
172            })),
173        }];
174
175        let run = SensorRunMeta {
176            started_at: self.started_at,
177            ended_at: self.ended_at,
178            duration_ms: self.duration_ms,
179            capabilities: SensorCapabilities {
180                baseline: if self.baseline_available {
181                    Capability {
182                        status: CapabilityStatus::Available,
183                        reason: None,
184                    }
185                } else {
186                    Capability {
187                        status: CapabilityStatus::Unavailable,
188                        reason: self.baseline_reason.clone(),
189                    }
190                },
191                engine: None,
192            },
193        };
194
195        SensorReport {
196            schema: SENSOR_REPORT_SCHEMA_V1.to_string(),
197            tool: self.tool,
198            run,
199            verdict: SensorVerdict {
200                status: SensorVerdictStatus::Fail,
201                counts: SensorVerdictCounts {
202                    info: 0,
203                    warn: 0,
204                    error: 1,
205                },
206                reasons: vec!["tool_error".to_string()],
207            },
208            findings,
209            artifacts: self.artifacts,
210            data: serde_json::json!({ "error": message }),
211        }
212    }
213
214    /// Build an aggregated report from multiple PerfgateReports.
215    pub fn build_aggregated(self, outcomes: &[BenchOutcome]) -> (SensorReport, String) {
216        let mut findings = Vec::new();
217        let mut pass_count = 0;
218        let mut warn_count = 0;
219        let mut fail_count = 0;
220        let mut all_reasons = Vec::new();
221        let mut worst_status = SensorVerdictStatus::Skip;
222        let mut all_markdown = String::new();
223        let mut artifacts = self.artifacts;
224
225        for outcome in outcomes {
226            match outcome {
227                BenchOutcome::Success {
228                    bench_name,
229                    report,
230                    markdown,
231                    extras_prefix,
232                } => {
233                    pass_count += report.summary.pass_count;
234                    warn_count += report.summary.warn_count;
235                    fail_count += report.summary.fail_count;
236
237                    match report.verdict.status {
238                        perfgate_types::VerdictStatus::Fail => {
239                            worst_status = SensorVerdictStatus::Fail;
240                        }
241                        perfgate_types::VerdictStatus::Warn => {
242                            if worst_status != SensorVerdictStatus::Fail {
243                                worst_status = SensorVerdictStatus::Warn;
244                            }
245                        }
246                        perfgate_types::VerdictStatus::Pass => {
247                            if worst_status == SensorVerdictStatus::Skip {
248                                worst_status = SensorVerdictStatus::Pass;
249                            }
250                        }
251                        perfgate_types::VerdictStatus::Skip => {}
252                    }
253
254                    for reason in &report.verdict.reasons {
255                        if !all_reasons.contains(reason) {
256                            all_reasons.push(reason.clone());
257                        }
258                    }
259
260                    for f in &report.findings {
261                        let mut finding_data =
262                            f.data.as_ref().map(|d| serde_json::to_value(d).unwrap());
263                        if let Some(obj) = finding_data.as_mut().and_then(|v| v.as_object_mut()) {
264                            obj.insert("bench_name".to_string(), serde_json::json!(bench_name));
265                        } else {
266                            finding_data = Some(serde_json::json!({ "bench_name": bench_name }));
267                        }
268
269                        findings.push(SensorFinding {
270                            check_id: f.check_id.clone(),
271                            code: f.code.clone(),
272                            severity: map_severity(f.severity),
273                            message: format!("[{}] {}", bench_name, f.message),
274                            fingerprint: Some(sha256_hex(
275                                format!("{}:{}:{}", bench_name, f.check_id, f.code).as_bytes(),
276                            )),
277                            data: finding_data,
278                        });
279                    }
280
281                    if let Some(prefix) = extras_prefix {
282                        artifacts.push(SensorArtifact {
283                            path: format!("{}/perfgate.run.v1.json", prefix),
284                            artifact_type: "run_receipt".to_string(),
285                        });
286                        if report.compare.is_some() {
287                            artifacts.push(SensorArtifact {
288                                path: format!("{}/perfgate.compare.v1.json", prefix),
289                                artifact_type: "compare_receipt".to_string(),
290                            });
291                        }
292                        artifacts.push(SensorArtifact {
293                            path: format!("{}/perfgate.report.v1.json", prefix),
294                            artifact_type: "perfgate_report".to_string(),
295                        });
296                    }
297
298                    if !all_markdown.is_empty() {
299                        all_markdown.push_str("\n---\n\n");
300                    }
301                    all_markdown.push_str(markdown);
302                }
303                BenchOutcome::Error {
304                    bench_name,
305                    error,
306                    stage,
307                    kind,
308                } => {
309                    worst_status = SensorVerdictStatus::Fail;
310                    fail_count += 1;
311                    if !all_reasons.contains(&"tool_error".to_string()) {
312                        all_reasons.push("tool_error".to_string());
313                    }
314                    findings.push(SensorFinding {
315                        check_id: perfgate_types::CHECK_ID_TOOL_RUNTIME.to_string(),
316                        code: perfgate_types::FINDING_CODE_RUNTIME_ERROR.to_string(),
317                        severity: SensorSeverity::Error,
318                        message: format!("[{}] tool error: {}", bench_name, error),
319                        fingerprint: Some(sha256_hex(
320                            format!("{}:{}:{}", bench_name, stage, kind).as_bytes(),
321                        )),
322                        data: Some(serde_json::json!({
323                            "bench_name": bench_name,
324                            "stage": stage,
325                            "error_kind": kind,
326                        })),
327                    });
328                }
329            }
330        }
331
332        findings.sort_by(|a, b| a.message.cmp(&b.message));
333
334        if findings.len() > self.max_findings {
335            truncate_findings(&mut findings, self.max_findings);
336        }
337
338        let run = SensorRunMeta {
339            started_at: self.started_at,
340            ended_at: self.ended_at,
341            duration_ms: self.duration_ms,
342            capabilities: SensorCapabilities {
343                baseline: if self.baseline_available {
344                    Capability {
345                        status: CapabilityStatus::Available,
346                        reason: None,
347                    }
348                } else {
349                    Capability {
350                        status: CapabilityStatus::Unavailable,
351                        reason: self.baseline_reason.clone(),
352                    }
353                },
354                engine: None,
355            },
356        };
357
358        artifacts.sort_by(|a, b| (&a.artifact_type, &a.path).cmp(&(&b.artifact_type, &b.path)));
359
360        let report = SensorReport {
361            schema: SENSOR_REPORT_SCHEMA_V1.to_string(),
362            tool: self.tool,
363            run,
364            verdict: SensorVerdict {
365                status: worst_status,
366                counts: SensorVerdictCounts {
367                    info: pass_count,
368                    warn: warn_count,
369                    error: fail_count,
370                },
371                reasons: all_reasons,
372            },
373            findings,
374            artifacts,
375            data: serde_json::json!({
376                "summary": {
377                    "bench_count": outcomes.len(),
378                    "pass_count": pass_count,
379                    "warn_count": warn_count,
380                    "fail_count": fail_count,
381                    "total_count": pass_count + warn_count + fail_count,
382                }
383            }),
384        };
385
386        (report, all_markdown)
387    }
388}
389
390#[derive(Debug, Clone)]
391pub enum BenchOutcome {
392    Success {
393        bench_name: String,
394        report: Box<PerfgateReport>,
395        markdown: String,
396        extras_prefix: Option<String>,
397    },
398    Error {
399        bench_name: String,
400        error: String,
401        stage: String,
402        kind: String,
403    },
404}
405
406fn map_severity(s: Severity) -> SensorSeverity {
407    match s {
408        Severity::Warn => SensorSeverity::Warn,
409        Severity::Fail => SensorSeverity::Error,
410    }
411}
412
413fn truncate_findings(findings: &mut Vec<SensorFinding>, limit: usize) {
414    let shown = limit.saturating_sub(1);
415    findings.truncate(shown);
416    findings.push(SensorFinding {
417        check_id: CHECK_ID_TOOL_TRUNCATION.to_string(),
418        code: FINDING_CODE_TRUNCATED.to_string(),
419        severity: SensorSeverity::Info,
420        message: "Some findings were truncated to stay within limits.".to_string(),
421        fingerprint: Some(sha256_hex(
422            format!("{}:{}", CHECK_ID_TOOL_TRUNCATION, FINDING_CODE_TRUNCATED).as_bytes(),
423        )),
424        data: None,
425    });
426}
427
428/// Generates a fingerprint for a set of findings.
429pub fn sensor_fingerprint(findings: &[SensorFinding]) -> String {
430    if findings.is_empty() {
431        return "".to_string();
432    }
433
434    let mut parts: Vec<String> = findings
435        .iter()
436        .map(|f| format!("{}:{}", f.check_id, f.code))
437        .collect();
438    parts.sort();
439    parts.dedup();
440
441    let combined = parts.join(",");
442    sha256_hex(combined.as_bytes())
443}
444
445pub fn default_engine_capability() -> SensorCapabilities {
446    SensorCapabilities {
447        baseline: Capability {
448            status: CapabilityStatus::Available,
449            reason: None,
450        },
451        engine: None,
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use perfgate_types::{REPORT_SCHEMA_V1, ReportSummary, Verdict, VerdictCounts, VerdictStatus};
459
460    pub(crate) fn make_tool_info() -> ToolInfo {
461        ToolInfo {
462            name: "perfgate".to_string(),
463            version: "0.1.0".to_string(),
464        }
465    }
466
467    pub(crate) fn make_pass_report() -> PerfgateReport {
468        PerfgateReport {
469            report_type: REPORT_SCHEMA_V1.to_string(),
470            verdict: Verdict {
471                status: VerdictStatus::Pass,
472                counts: VerdictCounts {
473                    pass: 3,
474                    warn: 0,
475                    fail: 0,
476                    skip: 0,
477                },
478                reasons: vec![],
479            },
480            compare: None,
481            findings: vec![],
482            summary: ReportSummary {
483                pass_count: 3,
484                warn_count: 0,
485                fail_count: 0,
486                skip_count: 0,
487                total_count: 3,
488            },
489        }
490    }
491
492    pub(crate) fn make_fail_report() -> PerfgateReport {
493        use perfgate_types::{FINDING_CODE_METRIC_FAIL, ReportFinding};
494        PerfgateReport {
495            report_type: REPORT_SCHEMA_V1.to_string(),
496            verdict: Verdict {
497                status: VerdictStatus::Fail,
498                counts: VerdictCounts {
499                    pass: 1,
500                    warn: 0,
501                    fail: 2,
502                    skip: 0,
503                },
504                reasons: vec!["wall_ms_fail".to_string(), "max_rss_kb_fail".to_string()],
505            },
506            compare: None,
507            findings: vec![
508                ReportFinding {
509                    check_id: "perf.budget".to_string(),
510                    code: FINDING_CODE_METRIC_FAIL.to_string(),
511                    severity: Severity::Fail,
512                    message: "wall_ms regression: +30.00% (threshold: 20.0%)".to_string(),
513                    data: None,
514                },
515                ReportFinding {
516                    check_id: "perf.budget".to_string(),
517                    code: FINDING_CODE_METRIC_FAIL.to_string(),
518                    severity: Severity::Fail,
519                    message: "max_rss_kb regression: +25.00% (threshold: 15.0%)".to_string(),
520                    data: None,
521                },
522            ],
523            summary: ReportSummary {
524                pass_count: 1,
525                warn_count: 0,
526                fail_count: 2,
527                skip_count: 0,
528                total_count: 3,
529            },
530        }
531    }
532
533    pub(crate) fn make_warn_report() -> PerfgateReport {
534        use perfgate_types::{FINDING_CODE_METRIC_WARN, ReportFinding};
535        PerfgateReport {
536            report_type: REPORT_SCHEMA_V1.to_string(),
537            verdict: Verdict {
538                status: VerdictStatus::Warn,
539                counts: VerdictCounts {
540                    pass: 1,
541                    warn: 1,
542                    fail: 0,
543                    skip: 0,
544                },
545                reasons: vec!["wall_ms_warn".to_string()],
546            },
547            compare: None,
548            findings: vec![ReportFinding {
549                check_id: "perf.budget".to_string(),
550                code: FINDING_CODE_METRIC_WARN.to_string(),
551                severity: Severity::Warn,
552                message: "wall_ms regression: +15.00%".to_string(),
553                data: None,
554            }],
555            summary: ReportSummary {
556                pass_count: 1,
557                warn_count: 1,
558                fail_count: 0,
559                skip_count: 0,
560                total_count: 2,
561            },
562        }
563    }
564
565    #[test]
566    fn test_build_pass_sensor_report() {
567        let report = make_pass_report();
568        let builder =
569            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
570        let sensor_report = builder.build(&report);
571
572        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Pass);
573        assert_eq!(sensor_report.verdict.counts.info, 3);
574    }
575
576    #[test]
577    fn test_build_fail_sensor_report() {
578        let report = make_fail_report();
579        let builder =
580            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
581        let sensor_report = builder.build(&report);
582
583        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Fail);
584        assert_eq!(sensor_report.verdict.counts.error, 2);
585        assert_eq!(sensor_report.findings.len(), 2);
586    }
587
588    #[test]
589    fn test_build_warn_sensor_report() {
590        let report = make_warn_report();
591        let builder =
592            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
593        let sensor_report = builder.build(&report);
594
595        assert_eq!(sensor_report.verdict.status, SensorVerdictStatus::Warn);
596        assert_eq!(sensor_report.verdict.counts.warn, 1);
597    }
598
599    #[test]
600    fn test_build_aggregated_single_bench_matches_build() {
601        let report = make_warn_report();
602        let outcome = BenchOutcome::Success {
603            bench_name: "bench-a".to_string(),
604            report: Box::new(report.clone()),
605            markdown: "md".to_string(),
606            extras_prefix: None,
607        };
608
609        let builder =
610            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
611        let (agg_report, _) = builder.build_aggregated(&[outcome]);
612
613        assert_eq!(agg_report.verdict.status, SensorVerdictStatus::Warn);
614        assert_eq!(agg_report.verdict.counts.warn, 1);
615        assert_eq!(agg_report.findings.len(), 1);
616    }
617
618    #[test]
619    fn test_build_aggregated_multi_bench_counts_summed() {
620        let report_a = make_warn_report();
621        let report_b = make_fail_report();
622
623        let outcome_a = BenchOutcome::Success {
624            bench_name: "bench-a".to_string(),
625            report: Box::new(report_a),
626            markdown: "md-a".to_string(),
627            extras_prefix: None,
628        };
629        let outcome_b = BenchOutcome::Success {
630            bench_name: "bench-b".to_string(),
631            report: Box::new(report_b),
632            markdown: "md-b".to_string(),
633            extras_prefix: None,
634        };
635
636        let builder =
637            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
638        let (agg_report, _) = builder.build_aggregated(&[outcome_a, outcome_b]);
639
640        assert_eq!(agg_report.verdict.status, SensorVerdictStatus::Fail);
641        assert_eq!(agg_report.verdict.counts.info, 2);
642        assert_eq!(agg_report.verdict.counts.warn, 1);
643        assert_eq!(agg_report.verdict.counts.error, 2);
644    }
645
646    #[test]
647    fn test_build_aggregated_mixed_success_and_error() {
648        let report_a = make_pass_report();
649        let outcome_a = BenchOutcome::Success {
650            bench_name: "bench-a".to_string(),
651            report: Box::new(report_a),
652            markdown: "md-a".to_string(),
653            extras_prefix: None,
654        };
655        let outcome_b = BenchOutcome::Error {
656            bench_name: "bench-b".to_string(),
657            error: "boom".to_string(),
658            stage: "run_command".to_string(),
659            kind: "error".to_string(),
660        };
661
662        let builder =
663            SensorReportBuilder::new(make_tool_info(), "2024-01-01T00:00:00Z".to_string());
664        let (agg_report, _) = builder.build_aggregated(&[outcome_a, outcome_b]);
665
666        assert_eq!(agg_report.verdict.status, SensorVerdictStatus::Fail); // Error maps to fail
667        assert_eq!(agg_report.verdict.counts.info, 3);
668        assert_eq!(agg_report.findings.len(), 1); // Only the error finding
669    }
670}
671
672#[cfg(test)]
673mod snapshot_tests {
674    use super::*;
675    use insta::assert_json_snapshot;
676    use perfgate_types::{
677        FindingData, ReportFinding, ReportSummary, Verdict, VerdictCounts, VerdictStatus,
678    };
679
680    #[test]
681    fn snapshot_pass_report() {
682        let report = tests::make_pass_report();
683        let sensor_report =
684            SensorReportBuilder::new(tests::make_tool_info(), "2024-01-15T10:30:00Z".to_string())
685                .ended_at("2024-01-15T10:31:00Z".to_string(), 60000)
686                .build(&report);
687
688        assert_json_snapshot!(sensor_report);
689    }
690
691    #[test]
692    fn snapshot_fail_report() {
693        let report = tests::make_fail_report();
694        let sensor_report =
695            SensorReportBuilder::new(tests::make_tool_info(), "2024-01-15T10:30:00Z".to_string())
696                .ended_at("2024-01-15T10:31:00Z".to_string(), 60000)
697                .baseline(true, None)
698                .build(&report);
699
700        assert_json_snapshot!(sensor_report);
701    }
702
703    #[test]
704    fn snapshot_aggregated_multi_bench() {
705        let report_a = tests::make_warn_report();
706        let outcome_a = BenchOutcome::Success {
707            bench_name: "bench-a".to_string(),
708            report: Box::new(report_a),
709            markdown: "markdown a".to_string(),
710            extras_prefix: Some("artifacts/bench-a".to_string()),
711        };
712
713        let report_b = tests::make_pass_report();
714        let outcome_b = BenchOutcome::Success {
715            bench_name: "bench-b".to_string(),
716            report: Box::new(report_b),
717            markdown: "markdown b".to_string(),
718            extras_prefix: Some("artifacts/bench-b".to_string()),
719        };
720
721        let (sensor_report, _md) =
722            SensorReportBuilder::new(tests::make_tool_info(), "2024-01-15T10:30:00Z".to_string())
723                .ended_at("2024-01-15T10:32:00Z".to_string(), 120000)
724                .build_aggregated(&[outcome_a, outcome_b]);
725
726        assert_json_snapshot!(sensor_report);
727    }
728
729    #[test]
730    fn snapshot_truncated_report() {
731        use perfgate_types::FINDING_CODE_METRIC_FAIL;
732        let findings: Vec<ReportFinding> = (0..5)
733            .map(|i| ReportFinding {
734                check_id: "perf.budget".to_string(),
735                code: FINDING_CODE_METRIC_FAIL.to_string(),
736                severity: Severity::Fail,
737                message: format!("metric_{} regression", i),
738                data: Some(FindingData {
739                    metric_name: format!("metric_{}", i),
740                    baseline: 100.0,
741                    current: 150.0,
742                    regression_pct: 50.0,
743                    threshold: 0.2,
744                    direction: perfgate_types::Direction::Lower,
745                }),
746            })
747            .collect();
748
749        let report = PerfgateReport {
750            report_type: perfgate_types::REPORT_SCHEMA_V1.to_string(),
751            verdict: Verdict {
752                status: VerdictStatus::Fail,
753                counts: VerdictCounts {
754                    pass: 0,
755                    warn: 0,
756                    fail: 5,
757                    skip: 0,
758                },
759                reasons: vec!["truncated".to_string()],
760            },
761            compare: None,
762            findings,
763            summary: ReportSummary {
764                pass_count: 0,
765                warn_count: 0,
766                fail_count: 5,
767                skip_count: 0,
768                total_count: 5,
769            },
770        };
771
772        let sensor_report =
773            SensorReportBuilder::new(tests::make_tool_info(), "2024-01-15T10:30:00Z".to_string())
774                .ended_at("2024-01-15T10:31:00Z".to_string(), 60000)
775                .baseline(true, None)
776                .max_findings(3)
777                .build(&report);
778
779        assert_json_snapshot!(sensor_report);
780    }
781}