1use 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
15pub const FINDING_CODE_TRUNCATED: &str = "tool_truncation";
17
18pub const CHECK_ID_TOOL_TRUNCATION: &str = "perfgate.tool";
20
21pub 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 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 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 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
430pub 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); assert_eq!(agg_report.verdict.counts.info, 3);
673 assert_eq!(agg_report.findings.len(), 1); }
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}