Skip to main content

perfgate_sensor/
lib.rs

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