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