1use 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
13pub const FINDING_CODE_TRUNCATED: &str = "tool_truncation";
15
16pub const CHECK_ID_TOOL_TRUNCATION: &str = "perfgate.tool";
18
19pub 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 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 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 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
428pub 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); assert_eq!(agg_report.verdict.counts.info, 3);
668 assert_eq!(agg_report.findings.len(), 1); }
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}